dev add google search by image
Test Workflow / test (push) Successful in 3s

This commit is contained in:
vladp
2026-01-27 05:41:47 +07:00
parent e7c6df12d6
commit 21e31c7d0f
6 changed files with 318 additions and 1 deletions
@@ -0,0 +1,22 @@
package ru.soune.nocopy.configuration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.web.client.RestTemplate;
@Configuration
public class RestTemplateConfig {
@Bean
public RestTemplate restTemplate() {
RestTemplate restTemplate = new RestTemplate();
SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory();
requestFactory.setConnectTimeout(30000);
requestFactory.setReadTimeout(60000);
restTemplate.setRequestFactory(requestFactory);
return restTemplate;
}
}
@@ -0,0 +1,68 @@
package ru.soune.nocopy.dto.file;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
import java.util.List;
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public class GoogleVisionSearchResponse {
@JsonProperty("bestGuessLabels")
private List<BestGuessLabel> bestGuessLabels;
@JsonProperty("fullMatchingImages")
private List<ImageResult> fullMatchingImages;
@JsonProperty("visuallySimilarImages")
private List<ImageResult> visuallySimilarImages;
@JsonProperty("pagesWithMatchingImages")
private List<PageResult> pagesWithMatchingImages;
@JsonProperty("partialMatchingImages")
private List<ImageResult> partialMatchingImages;
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public static class BestGuessLabel {
@JsonProperty("label")
private String label;
@JsonProperty("languageCode")
private String languageCode;
}
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public static class ImageResult {
@JsonProperty("url")
private String url;
@JsonProperty("score")
private Float score;
@JsonProperty("height")
private Integer height;
@JsonProperty("width")
private Integer width;
}
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public static class PageResult {
@JsonProperty("url")
private String url;
@JsonProperty("pageTitle")
private String pageTitle;
@JsonProperty("fullMatchingImages")
private List<ImageResult> fullMatchingImages;
@JsonProperty("partialMatchingImages")
private List<ImageResult> partialMatchingImages;
}
}
@@ -3,13 +3,14 @@ package ru.soune.nocopy.handler;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import ru.soune.nocopy.dto.BaseRequest;
import ru.soune.nocopy.dto.BaseResponse;
import ru.soune.nocopy.dto.MessageCode;
import ru.soune.nocopy.dto.file.GoogleVisionSearchResponse;
import ru.soune.nocopy.dto.file.ImageSearchRequest;
import ru.soune.nocopy.dto.file.YandexSearchResponse;
import ru.soune.nocopy.service.GoogleVisionSearchService;
import ru.soune.nocopy.service.YandexSearchService;
@Slf4j
@@ -21,6 +22,8 @@ public class ImageFoundRequestHandler implements RequestHandler {
private final YandexSearchService yandexSearchService;
private final GoogleVisionSearchService googleVisionSearchService;
@Override
public BaseResponse handle(BaseRequest request) throws Exception {
ImageSearchRequest imageSearchRequest = objectMapper.convertValue(request.getMessageBody(),
@@ -30,6 +33,9 @@ public class ImageFoundRequestHandler implements RequestHandler {
YandexSearchResponse response = yandexSearchService.searchByFileEntity(fileId);
//TODO uncommited when add billing
// GoogleVisionSearchResponse googleVisionSearchResponse = googleVisionSearchService.searchByFileEntity(fileId);
return new BaseResponse(request.getMsgId(), MessageCode.SUCCESS.getCode(),
MessageCode.SUCCESS.getDescription(), response);
}
@@ -0,0 +1,205 @@
package ru.soune.nocopy.service;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.auth.oauth2.GoogleCredentials;
import com.google.cloud.vision.v1.*;
import com.google.protobuf.ByteString;
import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Service;
import ru.soune.nocopy.dto.BaseResponse;
import ru.soune.nocopy.dto.MessageCode;
import ru.soune.nocopy.dto.file.GoogleVisionSearchResponse;
import ru.soune.nocopy.entity.file.FileEntity;
import ru.soune.nocopy.exception.NotValidFieldException;
import ru.soune.nocopy.repository.FileEntityRepository;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
@Slf4j
@Service
@RequiredArgsConstructor
public class GoogleVisionSearchService {
private final FileEntityRepository fileEntityRepository;
private final ObjectMapper objectMapper;
private ImageAnnotatorClient visionClient;
@PostConstruct
public void init() throws IOException {
objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
objectMapper.configure(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY, true);
initVisionClient();
}
private void initVisionClient() throws IOException {
try (InputStream credentialsStream = new ClassPathResource("config/google-service-account.json").getInputStream()) {
GoogleCredentials credentials = GoogleCredentials.fromStream(credentialsStream);
ImageAnnotatorSettings settings = ImageAnnotatorSettings.newBuilder()
.setCredentialsProvider(() -> credentials)
.build();
this.visionClient = ImageAnnotatorClient.create(settings);
}
}
public GoogleVisionSearchResponse searchByFileEntity(String fileId) throws IOException {
FileEntity fileEntity = fileEntityRepository.findById(fileId)
.orElseThrow(() -> {
throw new NotValidFieldException("File not found", new BaseResponse(20007,
MessageCode.FILE_NOT_FOUND.getCode(), MessageCode.FILE_NOT_FOUND.getDescription(),
Map.of("fileId", fileId)));
});
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())));
}
byte[] fileBytes;
try {
fileBytes = readFileFromDisk(fileEntity);
} catch (IOException e) {
throw new NotValidFieldException("File not found or cannot read file", new BaseResponse(20007,
MessageCode.FILE_NOT_FOUND.getCode(), MessageCode.FILE_NOT_FOUND.getDescription(),
Map.of("fileId", fileId,
"filePath", fileEntity.getFilePath())));
}
return callGoogleVisionApi(fileBytes);
}
private boolean isImageFile(FileEntity fileEntity) {
String mimeType = fileEntity.getMimeType();
return mimeType != null && mimeType.startsWith("image");
}
private byte[] readFileFromDisk(FileEntity fileEntity) throws IOException {
Path filePath = Path.of(fileEntity.getFilePath());
if (!Files.exists(filePath) || !Files.isReadable(filePath)) {
throw new IOException("Cannot read file: " + fileEntity.getFilePath());
}
return Files.readAllBytes(filePath);
}
private GoogleVisionSearchResponse callGoogleVisionApi(byte[] imageBytes) throws IOException {
ByteString imgBytes = ByteString.copyFrom(imageBytes);
Image image = Image.newBuilder()
.setContent(imgBytes)
.build();
Feature feature = Feature.newBuilder()
.setType(Feature.Type.WEB_DETECTION)
.setMaxResults(20)
.build();
AnnotateImageRequest request = AnnotateImageRequest.newBuilder()
.addFeatures(feature)
.setImage(image)
.build();
BatchAnnotateImagesResponse response = visionClient.batchAnnotateImages(List.of(request));
if (response.getResponsesCount() == 0) {
throw new IOException("Empty response from Google Vision API");
}
AnnotateImageResponse singleResponse = response.getResponses(0);
if (singleResponse.hasError()) {
throw new IOException("Google Vision API error: " + singleResponse.getError().getMessage());
}
return convertToResponse(singleResponse.getWebDetection());
}
private GoogleVisionSearchResponse convertToResponse(WebDetection webDetection) {
GoogleVisionSearchResponse response = new GoogleVisionSearchResponse();
if (webDetection.getBestGuessLabelsCount() > 0) {
response.setBestGuessLabels(
webDetection.getBestGuessLabelsList().stream()
.map(label -> {
GoogleVisionSearchResponse.BestGuessLabel dtoLabel =
new GoogleVisionSearchResponse.BestGuessLabel();
dtoLabel.setLabel(label.getLabel());
dtoLabel.setLanguageCode(label.getLanguageCode());
return dtoLabel;
})
.toList()
);
}
if (webDetection.getFullMatchingImagesCount() > 0) {
response.setFullMatchingImages(
webDetection.getFullMatchingImagesList().stream()
.map(img -> {
GoogleVisionSearchResponse.ImageResult dtoImg =
new GoogleVisionSearchResponse.ImageResult();
dtoImg.setUrl(img.getUrl());
dtoImg.setScore(img.getScore());
return dtoImg;
})
.toList()
);
}
if (webDetection.getVisuallySimilarImagesCount() > 0) {
response.setVisuallySimilarImages(
webDetection.getVisuallySimilarImagesList().stream()
.map(img -> {
GoogleVisionSearchResponse.ImageResult dtoImg =
new GoogleVisionSearchResponse.ImageResult();
dtoImg.setUrl(img.getUrl());
dtoImg.setScore(img.getScore());
return dtoImg;
})
.toList()
);
}
if (webDetection.getPagesWithMatchingImagesCount() > 0) {
response.setPagesWithMatchingImages(
webDetection.getPagesWithMatchingImagesList().stream()
.map(page -> {
GoogleVisionSearchResponse.PageResult dtoPage =
new GoogleVisionSearchResponse.PageResult();
dtoPage.setUrl(page.getUrl());
dtoPage.setPageTitle(page.getPageTitle());
return dtoPage;
})
.toList()
);
}
if (webDetection.getPartialMatchingImagesCount() > 0) {
response.setPartialMatchingImages(
webDetection.getPartialMatchingImagesList().stream()
.map(img -> {
GoogleVisionSearchResponse.ImageResult dtoImg =
new GoogleVisionSearchResponse.ImageResult();
dtoImg.setUrl(img.getUrl());
dtoImg.setScore(img.getScore());
return dtoImg;
})
.toList()
);
}
return response;
}
}
@@ -0,0 +1,13 @@
{
"type": "service_account",
"project_id": "ncp-1-483610",
"private_key_id": "94ea762d9a56a3362155ab33f8959bfec5ae8b42",
"private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCwjY9sYQfeqHwr\nqKgr8sw3Qfao/vzTa9wnmbZvrM6UJ3ukMkj08w50rP0udKxTRWnH6rpSQnD696GS\nY0/qrjEO1LeMdPJ+R0v8B+Z3PlpuQtfmxXALF4QbGQxeWg9NK7o4JM0kANkV80me\nxo6T7WnywQm1SfBIPZjwLBhkn7pfcdTDJFiZkH073yB6/tPsgd6TRQDmlvGsrGmq\nCf8E/DhxaFIHUpoUEGpxeuFPmvAicEWdURCtCXeiAxRQv+Gy0mP7uGGY6C3SeYfJ\n5PNLopgnN2ATCeIp1EjJnH28sAEvE0e/K/9z4ZThgh1mB1CaLkaQweq3YQWoiyGF\nfcoPpJ8LAgMBAAECggEABluSppx35BS9I/VW2P5NTFAbycok4JgpvWNTpoHajos9\ncJQ+/FMkWh9AnsOk0PrW7FQPkZbC6sawEs1wu0q6iYYvdedgNBvtKI5ARlsAdqgB\njlXdywh1wWQNhfhIRMcmVY89s+Yz6w5vwD/2Mm2COzOoXdbjyDYvo7ZyymzWEUnA\nwaxg7aF5cGC+iqf+l2Ym8XdY2ZavvyZ8RwgKfQGfcPuhMogg+H6au4yvXPBNxv0u\nxDCbOm2ezA4cLyKyFqi6S5KiGmk2LnCOoLrkIalmTQhnptFZofGs4ezuOvhZ6ZTi\nQVDayHzbBygmFzJn1wzw83IQ7QgMmWYoEjC+JcRlgQKBgQDg6U3QPxhdvX1dCzBj\nef6yUrIeJhiILvKnAzi6MUhaWP0mU6kcIIn8DS78ojML+leKh2R7JQFFrt87kHjK\npcaTC7wkgNCa6e3d+U95EIVUvPVrvQ5O4zIM7evTiPEeZhN9Dhuh0o3eeYxfIqZO\nsN0y8F/xPWdGW5CcdL7dcTOUkwKBgQDI9Qzr7poRaHM8jzS0M4Q4cVsYdC2P6KgI\nWD9Qbbl1rVi14YLt1FSd4w7U4YJ/kaWOrdtKklqAvM3oq5J0oEQekpixsxFvXdW+\ngJTzOoOq2lgCThBagwVKOFkAH/CwPkhFPwuVBgfVNiajbrcGF6i5VWzIHCXT+IDe\naSw64dqOqQKBgDp9EYpNTjXaeEaBCWVlLVIMZVunxotrwhiiotbwyAMOz05vRTQW\nVivg9c4nFCVSRf+1c/D+T5Vig5UG3hK9B6Xn0Fah1R3kJcKq+frey/2cYipRcO4c\n8UAhg0lwfFvOadUEnTT4/4HSlCmNZjhikDOWBS1ELZ5DY5j8V0JZFPPXAoGAXi0B\nLjw2dbwGbTYLk/ukljMBZvdjNtLolGiO22lghbaEIVCa5Ewij4+OLtO0LYabGL9/\nSnZF9ZkFwmlNjFxjMBSxfG2X2SIXflyR8V7Vv6btob7lyRUn0H2RsA5H5MB7bAA8\ntE0MNK5Y8zR6j19dEeXnwev3ClymQBT3xmx72WkCgYAEhx+piCTvD+2SFBweM3J4\ng6LefBXFWHSlftQF1Er5mI+5kr4i3M5d6zh8lKcLpygSS1q5/03CMUxEiLTZMLmG\nVX/+UgEWzdEe1Dr/7YPYcOSPiBpTRyPiq6KQC/KtPPWHouGyaPO1SAAZhlk97dQs\n9mosf4Re69Re8U6MxxGw9Q==\n-----END PRIVATE KEY-----\n",
"client_email": "image-search-service@ncp-1-483610.iam.gserviceaccount.com",
"client_id": "105492884344006453429",
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
"client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/image-search-service%40ncp-1-483610.iam.gserviceaccount.com",
"universe_domain": "googleapis.com"
}