package ru.soune.nocopy.service.search; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.DeserializationFeature; import jakarta.annotation.PostConstruct; import lombok.extern.slf4j.Slf4j; import okhttp3.*; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import ru.soune.nocopy.dto.BaseResponse; import ru.soune.nocopy.dto.MessageCode; import ru.soune.nocopy.dto.file.YandexSearchResponse; import ru.soune.nocopy.entity.file.FileEntity; import ru.soune.nocopy.exception.NotValidFieldException; import java.io.*; import java.net.InetSocketAddress; import java.net.Proxy; import java.net.SocketTimeoutException; import java.util.*; import java.util.concurrent.*; @Slf4j @Service public class SearchImageService { private final ObjectMapper objectMapper; private final OkHttpClient yandexHttpClient; private final OkHttpClient googleHttpClient; public SearchImageService() { Proxy proxy = new Proxy(Proxy.Type.HTTP, new InetSocketAddress("193.46.217.94", 3128)); this.yandexHttpClient = new OkHttpClient.Builder() .proxy(proxy) .connectTimeout(35, TimeUnit.SECONDS) .writeTimeout(35, TimeUnit.SECONDS) .readTimeout(35, TimeUnit.SECONDS) .callTimeout(35, TimeUnit.SECONDS) .followRedirects(true) .followSslRedirects(true) .retryOnConnectionFailure(true) .build(); this.googleHttpClient = new OkHttpClient.Builder() .proxy(proxy) .connectTimeout(35, TimeUnit.SECONDS) .writeTimeout(35, TimeUnit.SECONDS) .readTimeout(35, TimeUnit.SECONDS) .callTimeout(35, TimeUnit.SECONDS) .followRedirects(true) .followSslRedirects(true) .retryOnConnectionFailure(true) .build(); this.objectMapper = new ObjectMapper(); } @Value("${yandex.api-key}") private String apiKey; @Value("${yandex.folder-id}") private String folderId; @Value("${yandex.search-url}") private String searchUrl; @Value("${searchapi.api-key:}") private String searchApiKey; @Value("${server.baseurl}") private String appBaseUrl; @PostConstruct public void init() { objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); objectMapper.configure(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY, true); } public String searchReverseByPublicUrl(FileEntity fileEntity, String engine, String searchType) throws IOException, TimeoutException { if (!isImageFile(fileEntity)) { throw new NotValidFieldException( "File not image", new BaseResponse( 20007, MessageCode.INVALID_FIELD.getCode(), MessageCode.INVALID_FIELD.getDescription(), Map.of("file_type", fileEntity.getMimeType()) ) ); } String publicUrl = String.format("%s/api/files/public/%s", appBaseUrl, fileEntity.getId()); log.info("Searching reverse for image: {}", publicUrl); try { return callReverseImageApiByUrl(publicUrl, engine, searchType); } catch (SocketTimeoutException e) { log.error("Yandex search timeout after {}", fileEntity.getId()); throw new TimeoutException("Search timeout"); } } private boolean isImageFile(FileEntity fileEntity) { String mimeType = fileEntity.getMimeType(); return mimeType != null && mimeType.startsWith("image"); } private String callReverseImageApiByUrl(String imageUrl, String engine, String searchType) throws IOException { if (searchApiKey == null || searchApiKey.isBlank()) { throw new IllegalStateException("SearchAPI key not configured"); } OkHttpClient client = engine.contains("yandex_reverse_image") ? yandexHttpClient : googleHttpClient; HttpUrl url = HttpUrl.parse("https://www.searchapi.io/api/v1/search") .newBuilder() .addQueryParameter("engine", engine) .addQueryParameter("api_key", searchApiKey) .addQueryParameter("url", imageUrl) .addQueryParameter("search_type", searchType) .addQueryParameter("t_", String.valueOf(System.currentTimeMillis())) .build(); Request request = new Request.Builder() .url(url) .header("Accept", "application/json") .header("User-Agent", "Mozilla/5.0") .build(); long start = System.currentTimeMillis(); try (Response response = client.newCall(request).execute()) { long duration = System.currentTimeMillis() - start; log.info("SearchAPI response code={}, duration={}ms, engine={}", response.code(), duration, engine); if (!response.isSuccessful()) { String errorBody = response.body() != null ? response.body().string() : "null"; throw new IOException("API error " + response.code() + ": " + errorBody); } ResponseBody body = response.body(); if (body == null) { throw new IOException("Empty response body"); } return body.string(); } } public List getAllImagesWithoutPagination(String searchApiJson, String findType) throws IOException { JsonNode root = objectMapper.readTree(searchApiJson); JsonNode matches = root.path(findType); List allImages = new ArrayList<>(); if (matches.isArray()) { for (JsonNode match : matches) { YandexSearchResponse.ImageResult result = mapImageResult(match); if ("exact_matches".equals(findType)) { JsonNode thumbnail = match.path("thumbnail"); if (!thumbnail.isMissingNode() && thumbnail.asText().startsWith("data:image")) { result.setUrl(thumbnail.asText()); } JsonNode imageNode = match.path("image"); if (!imageNode.isMissingNode()) { String directUrl = imageNode.path("link").asText(); if (directUrl != null && !directUrl.isBlank()) { result.setUrl(directUrl); } } } if (result.getUrl() != null && !result.getUrl().isBlank()) { allImages.add(result); } } } return allImages; } public List removeDuplicateUrls( List yandexImages, List googleImages) { Map uniqueByUrl = new LinkedHashMap<>(); for (YandexSearchResponse.ImageResult image : googleImages) { String normalizedUrl = normalizeUrl(image.getUrl()); uniqueByUrl.putIfAbsent(normalizedUrl, image); } for (YandexSearchResponse.ImageResult image : yandexImages) { String normalizedUrl = normalizeUrl(image.getUrl()); uniqueByUrl.putIfAbsent(normalizedUrl, image); } return new ArrayList<>(uniqueByUrl.values()); } public List paginateResults( List allResults, int page, int pageSize) { if (allResults.isEmpty()) { return new ArrayList<>(); } int totalPages = (int) Math.ceil((double) allResults.size() / pageSize); if (page > totalPages) { return new ArrayList<>(); } int startIndex = (page - 1) * pageSize; int endIndex = Math.min(startIndex + pageSize, allResults.size()); return new ArrayList<>(allResults.subList(startIndex, endIndex)); } private String normalizeUrl(String url) { if (url == null) return null; try { if (url.startsWith("data:image")) { return url.length() > 100 ? url.substring(0, 100) : url; } java.net.URI uri = new java.net.URI(url); String host = uri.getHost(); if (host != null) { host = host.toLowerCase().replaceFirst("^www\\.", ""); } String path = uri.getPath(); if (path != null && path.length() > 1 && path.endsWith("/")) { path = path.substring(0, path.length() - 1); } return (host != null ? host : "") + (path != null ? path : ""); } catch (Exception e) { return url.toLowerCase() .replaceFirst("^(https?://)?(www\\.)?", "") .replaceFirst("/$", ""); } } private YandexSearchResponse.ImageResult mapImageResult(JsonNode match) { YandexSearchResponse.ImageResult result = new YandexSearchResponse.ImageResult(); JsonNode imageNode = match.path("image"); if (imageNode.isObject()) { result.setUrl(imageNode.path("link").asText()); result.setWidth(imageNode.path("width").asInt(0)); result.setHeight(imageNode.path("height").asInt(0)); } result.setPageUrl(match.path("link").asText()); result.setPageTitle(match.path("title").asText()); String source = match.path("source").asText(); result.setHost(extractHostFromSource(source)); return result; } private String extractHostFromSource(String source) { if (source == null || source.isEmpty()) { return ""; } source = source.replaceFirst("^(https?://)?(www\\.)?", ""); int slashIndex = source.indexOf('/'); if (slashIndex > 0) { return source.substring(0, slashIndex); } return source; } }