.
This commit is contained in:
@@ -4,8 +4,8 @@ import com.fasterxml.jackson.databind.JsonNode;
|
||||
import kz.konturai.parser.config.FacebookLeadProperties;
|
||||
import kz.konturai.parser.model.FacebookLead;
|
||||
import kz.konturai.parser.repository.FacebookLeadRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||
import org.springframework.context.event.EventListener;
|
||||
@@ -13,6 +13,7 @@ import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
@@ -21,10 +22,10 @@ import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class FacebookLeadCollectorService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(FacebookLeadCollectorService.class);
|
||||
|
||||
private static final DateTimeFormatter FACEBOOK_TIMESTAMP_FORMAT =
|
||||
DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ssZ");
|
||||
|
||||
@@ -42,6 +43,28 @@ public class FacebookLeadCollectorService {
|
||||
private final FacebookLeadRepository facebookLeadRepository;
|
||||
private final FacebookLeadCallCenterSyncService callCenterSyncService;
|
||||
|
||||
public FacebookLeadCollectorService(
|
||||
FacebookLeadProperties properties,
|
||||
FacebookLeadGraphApiClient graphApiClient,
|
||||
FacebookLeadRepository facebookLeadRepository,
|
||||
FacebookLeadCallCenterSyncService callCenterSyncService) {
|
||||
this.properties = properties;
|
||||
this.graphApiClient = graphApiClient;
|
||||
this.facebookLeadRepository = facebookLeadRepository;
|
||||
this.callCenterSyncService = callCenterSyncService;
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
void logConfiguration() {
|
||||
log.info("Facebook hot lead collector initialized. pageId={}, accessTokenPresent={}, cron={}, graphApiVersion={}, postsPageSize={}, commentsPageSize={}",
|
||||
properties.getPageId(),
|
||||
hasAccessToken(),
|
||||
properties.getCron(),
|
||||
properties.getGraphApiVersion(),
|
||||
properties.getPostsPageSize(),
|
||||
properties.getCommentsPageSize());
|
||||
}
|
||||
|
||||
@EventListener(ApplicationReadyEvent.class)
|
||||
public void collectHotLeadsOnStartup() {
|
||||
runCollection("startup");
|
||||
@@ -53,8 +76,12 @@ public class FacebookLeadCollectorService {
|
||||
}
|
||||
|
||||
private void runCollection(String trigger) {
|
||||
log.info("Facebook hot lead trigger [{}] received. pageId={}, accessTokenPresent={}, cron={}",
|
||||
trigger, properties.getPageId(), hasAccessToken(), properties.getCron());
|
||||
|
||||
if (!isConfigured()) {
|
||||
log.warn("Facebook lead collection [{}] skipped: pageId/accessToken are not configured", trigger);
|
||||
log.warn("Facebook lead collection [{}] skipped: pageId/accessToken are not configured. pageId={}, accessTokenPresent={}, expectedEnvVar=FACEBOOK_LEADS_ACCESS_TOKEN",
|
||||
trigger, properties.getPageId(), hasAccessToken());
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -117,11 +144,17 @@ public class FacebookLeadCollectorService {
|
||||
String postId = post.path("id").asText(null);
|
||||
log.info("Processing Facebook post {} comments", StringUtils.hasText(postId) ? postId : "<unknown>");
|
||||
|
||||
int newLeadsCount = saveMatchingComments(post.path("comments").path("data"));
|
||||
String commentsAfterCursor = extractAfterCursor(post.path("comments"));
|
||||
if (!StringUtils.hasText(postId)) {
|
||||
log.warn("Skipping Facebook post without id while collecting comments");
|
||||
return 0;
|
||||
}
|
||||
|
||||
JsonNode firstCommentsPage = graphApiClient.fetchCommentsPage(postId, null);
|
||||
int newLeadsCount = saveMatchingComments(firstCommentsPage.path("data"));
|
||||
String commentsAfterCursor = extractAfterCursor(firstCommentsPage);
|
||||
int commentsPageNumber = 2;
|
||||
|
||||
while (StringUtils.hasText(postId) && StringUtils.hasText(commentsAfterCursor)) {
|
||||
while (StringUtils.hasText(commentsAfterCursor)) {
|
||||
log.info("Processing extra comments page {} for post {}. afterCursor={}",
|
||||
commentsPageNumber, postId, formatCursor(commentsAfterCursor));
|
||||
JsonNode commentsPage = graphApiClient.fetchCommentsPage(postId, commentsAfterCursor);
|
||||
@@ -225,6 +258,10 @@ public class FacebookLeadCollectorService {
|
||||
return StringUtils.hasText(properties.getPageId()) && StringUtils.hasText(properties.getAccessToken());
|
||||
}
|
||||
|
||||
private boolean hasAccessToken() {
|
||||
return StringUtils.hasText(properties.getAccessToken());
|
||||
}
|
||||
|
||||
private String formatCursor(String cursor) {
|
||||
return StringUtils.hasText(cursor) ? cursor : "<first-page>";
|
||||
}
|
||||
|
||||
@@ -2,19 +2,22 @@ package kz.konturai.parser.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import kz.konturai.parser.config.FacebookLeadProperties;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
import org.springframework.web.reactive.function.client.WebClientResponseException;
|
||||
|
||||
import java.net.URI;
|
||||
import java.time.Duration;
|
||||
|
||||
@Service
|
||||
@Slf4j
|
||||
public class FacebookLeadGraphApiClient {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(FacebookLeadGraphApiClient.class);
|
||||
|
||||
private final FacebookLeadProperties properties;
|
||||
private final WebClient facebookLeadWebClient;
|
||||
|
||||
@@ -26,25 +29,12 @@ public class FacebookLeadGraphApiClient {
|
||||
}
|
||||
|
||||
public JsonNode fetchPostsPage(String afterCursor) {
|
||||
String commentsField = "comments.limit(" + properties.getCommentsPageSize() + "){id,message,from,created_time}";
|
||||
log.info("Requesting Facebook posts page. pageId={}, afterCursor={}, limit={}",
|
||||
properties.getPageId(), formatCursor(afterCursor), properties.getPostsPageSize());
|
||||
|
||||
try {
|
||||
JsonNode response = facebookLeadWebClient.get()
|
||||
.uri(uriBuilder -> {
|
||||
var builder = uriBuilder
|
||||
.path("/{pageId}/posts")
|
||||
.queryParam("fields", "id," + commentsField)
|
||||
.queryParam("limit", properties.getPostsPageSize())
|
||||
.queryParam("access_token", properties.getAccessToken());
|
||||
|
||||
if (StringUtils.hasText(afterCursor)) {
|
||||
builder.queryParam("after", afterCursor);
|
||||
}
|
||||
|
||||
return builder.build(properties.getPageId());
|
||||
})
|
||||
.uri(URI.create(buildPostsUri(afterCursor)))
|
||||
.retrieve()
|
||||
.bodyToMono(JsonNode.class)
|
||||
.block(Duration.ofMillis(properties.getTimeoutMs()));
|
||||
@@ -65,19 +55,7 @@ public class FacebookLeadGraphApiClient {
|
||||
|
||||
try {
|
||||
JsonNode response = facebookLeadWebClient.get()
|
||||
.uri(uriBuilder -> {
|
||||
var builder = uriBuilder
|
||||
.path("/{postId}/comments")
|
||||
.queryParam("fields", "id,message,from,created_time")
|
||||
.queryParam("limit", properties.getCommentsPageSize())
|
||||
.queryParam("access_token", properties.getAccessToken());
|
||||
|
||||
if (StringUtils.hasText(afterCursor)) {
|
||||
builder.queryParam("after", afterCursor);
|
||||
}
|
||||
|
||||
return builder.build(postId);
|
||||
})
|
||||
.uri(URI.create(buildCommentsUri(postId, afterCursor)))
|
||||
.retrieve()
|
||||
.bodyToMono(JsonNode.class)
|
||||
.block(Duration.ofMillis(properties.getTimeoutMs()));
|
||||
@@ -101,6 +79,46 @@ public class FacebookLeadGraphApiClient {
|
||||
return StringUtils.hasText(afterCursor) ? afterCursor : "<none>";
|
||||
}
|
||||
|
||||
private String buildPostsUri(String afterCursor) {
|
||||
StringBuilder uri = new StringBuilder()
|
||||
.append(graphApiBaseUrl())
|
||||
.append("/")
|
||||
.append(properties.getPageId())
|
||||
.append("/posts?fields=id")
|
||||
.append("&limit=")
|
||||
.append(properties.getPostsPageSize())
|
||||
.append("&access_token=")
|
||||
.append(properties.getAccessToken());
|
||||
|
||||
if (StringUtils.hasText(afterCursor)) {
|
||||
uri.append("&after=").append(afterCursor);
|
||||
}
|
||||
|
||||
return uri.toString();
|
||||
}
|
||||
|
||||
private String buildCommentsUri(String postId, String afterCursor) {
|
||||
StringBuilder uri = new StringBuilder()
|
||||
.append(graphApiBaseUrl())
|
||||
.append("/")
|
||||
.append(postId)
|
||||
.append("/comments?fields=id,message,from,created_time")
|
||||
.append("&limit=")
|
||||
.append(properties.getCommentsPageSize())
|
||||
.append("&access_token=")
|
||||
.append(properties.getAccessToken());
|
||||
|
||||
if (StringUtils.hasText(afterCursor)) {
|
||||
uri.append("&after=").append(afterCursor);
|
||||
}
|
||||
|
||||
return uri.toString();
|
||||
}
|
||||
|
||||
private String graphApiBaseUrl() {
|
||||
return "https://graph.facebook.com/" + properties.getGraphApiVersion();
|
||||
}
|
||||
|
||||
private String formatCursor(String cursor) {
|
||||
return StringUtils.hasText(cursor) ? cursor : "<first-page>";
|
||||
}
|
||||
|
||||
@@ -47,6 +47,9 @@ logging.level.kz.konturai.parser.controller.MarketingController=DEBUG
|
||||
logging.level.kz.konturai.parser.service.MarketingAnalysisService=DEBUG
|
||||
logging.level.kz.konturai.parser.service.GeminiVideoGenerationService=INFO
|
||||
logging.level.kz.konturai.parser.service.NanoBananaImageGenerationService=INFO
|
||||
logging.level.kz.konturai.parser.service.FacebookLeadCollectorService=INFO
|
||||
logging.level.kz.konturai.parser.service.FacebookLeadGraphApiClient=INFO
|
||||
logging.level.kz.konturai.parser.service.FacebookLeadCallCenterSyncService=INFO
|
||||
logging.level.kz.konturai.parser.config.MongoConfig=WARN
|
||||
logging.level.org.springframework.data.mongodb.core.convert=WARN
|
||||
|
||||
|
||||
@@ -58,34 +58,37 @@ class FacebookLeadCollectorServiceTest {
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": "post-1",
|
||||
"comments": {
|
||||
"data": [
|
||||
{
|
||||
"id": "comment-1",
|
||||
"message": "Хочу купить, какая цена?",
|
||||
"created_time": "2026-04-05T08:15:30+0000",
|
||||
"from": {
|
||||
"name": "Alice"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "comment-2",
|
||||
"message": "Спасибо за пост",
|
||||
"created_time": "2026-04-05T08:16:30+0000",
|
||||
"from": {
|
||||
"name": "Bob"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "comment-3",
|
||||
"message": "НАПИШЕТЕ В ЛС",
|
||||
"created_time": "2026-04-05T08:17:30+0000",
|
||||
"from": {
|
||||
"name": "Carol"
|
||||
}
|
||||
}
|
||||
]
|
||||
"id": "post-1"
|
||||
}
|
||||
]
|
||||
}
|
||||
""");
|
||||
|
||||
JsonNode firstCommentsPage = objectMapper.readTree("""
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": "comment-1",
|
||||
"message": "Хочу купить, какая цена?",
|
||||
"created_time": "2026-04-05T08:15:30+0000",
|
||||
"from": {
|
||||
"name": "Alice"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "comment-2",
|
||||
"message": "Спасибо за пост",
|
||||
"created_time": "2026-04-05T08:16:30+0000",
|
||||
"from": {
|
||||
"name": "Bob"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "comment-3",
|
||||
"message": "НАПИШЕТЕ В ЛС",
|
||||
"created_time": "2026-04-05T08:17:30+0000",
|
||||
"from": {
|
||||
"name": "Carol"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -93,6 +96,7 @@ class FacebookLeadCollectorServiceTest {
|
||||
""");
|
||||
|
||||
when(graphApiClient.fetchPostsPage(null)).thenReturn(firstPostsPage);
|
||||
when(graphApiClient.fetchCommentsPage("post-1", null)).thenReturn(firstCommentsPage);
|
||||
when(facebookLeadRepository.existsByExternalCommentId("comment-1")).thenReturn(false);
|
||||
when(facebookLeadRepository.existsByExternalCommentId("comment-3")).thenReturn(true);
|
||||
when(facebookLeadRepository.save(any(FacebookLead.class))).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
@@ -121,24 +125,7 @@ class FacebookLeadCollectorServiceTest {
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": "post-1",
|
||||
"comments": {
|
||||
"data": [
|
||||
{
|
||||
"id": "comment-1",
|
||||
"message": "Сколько стоит?",
|
||||
"created_time": "2026-04-05T08:15:30+0000",
|
||||
"from": {
|
||||
"name": "Alice"
|
||||
}
|
||||
}
|
||||
],
|
||||
"paging": {
|
||||
"cursors": {
|
||||
"after": "comments-cursor-1"
|
||||
}
|
||||
}
|
||||
}
|
||||
"id": "post-1"
|
||||
}
|
||||
],
|
||||
"paging": {
|
||||
@@ -149,6 +136,26 @@ class FacebookLeadCollectorServiceTest {
|
||||
}
|
||||
""");
|
||||
|
||||
JsonNode firstCommentsPage = objectMapper.readTree("""
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": "comment-1",
|
||||
"message": "Сколько стоит?",
|
||||
"created_time": "2026-04-05T08:15:30+0000",
|
||||
"from": {
|
||||
"name": "Alice"
|
||||
}
|
||||
}
|
||||
],
|
||||
"paging": {
|
||||
"cursors": {
|
||||
"after": "comments-cursor-1"
|
||||
}
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
JsonNode secondCommentsPage = objectMapper.readTree("""
|
||||
{
|
||||
"data": [
|
||||
@@ -168,18 +175,21 @@ class FacebookLeadCollectorServiceTest {
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": "post-2",
|
||||
"comments": {
|
||||
"data": [
|
||||
{
|
||||
"id": "comment-3",
|
||||
"message": "цена",
|
||||
"created_time": "2026-04-05T08:17:30+0000",
|
||||
"from": {
|
||||
"name": "Carol"
|
||||
}
|
||||
}
|
||||
]
|
||||
"id": "post-2"
|
||||
}
|
||||
]
|
||||
}
|
||||
""");
|
||||
|
||||
JsonNode thirdCommentsPage = objectMapper.readTree("""
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": "comment-3",
|
||||
"message": "цена",
|
||||
"created_time": "2026-04-05T08:17:30+0000",
|
||||
"from": {
|
||||
"name": "Carol"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -187,8 +197,10 @@ class FacebookLeadCollectorServiceTest {
|
||||
""");
|
||||
|
||||
when(graphApiClient.fetchPostsPage(null)).thenReturn(firstPostsPage);
|
||||
when(graphApiClient.fetchCommentsPage("post-1", null)).thenReturn(firstCommentsPage);
|
||||
when(graphApiClient.fetchCommentsPage("post-1", "comments-cursor-1")).thenReturn(secondCommentsPage);
|
||||
when(graphApiClient.fetchPostsPage("posts-cursor-1")).thenReturn(secondPostsPage);
|
||||
when(graphApiClient.fetchCommentsPage("post-2", null)).thenReturn(thirdCommentsPage);
|
||||
when(facebookLeadRepository.existsByExternalCommentId(eq("comment-1"))).thenReturn(false);
|
||||
when(facebookLeadRepository.existsByExternalCommentId(eq("comment-2"))).thenReturn(false);
|
||||
when(facebookLeadRepository.existsByExternalCommentId(eq("comment-3"))).thenReturn(false);
|
||||
@@ -198,8 +210,10 @@ class FacebookLeadCollectorServiceTest {
|
||||
|
||||
assertEquals(3, savedCount);
|
||||
verify(graphApiClient).fetchPostsPage(null);
|
||||
verify(graphApiClient).fetchCommentsPage("post-1", null);
|
||||
verify(graphApiClient).fetchCommentsPage("post-1", "comments-cursor-1");
|
||||
verify(graphApiClient).fetchPostsPage("posts-cursor-1");
|
||||
verify(graphApiClient).fetchCommentsPage("post-2", null);
|
||||
verify(facebookLeadRepository, times(3)).save(any(FacebookLead.class));
|
||||
verify(callCenterSyncService, times(3)).syncLead(any(FacebookLead.class));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user