This commit is contained in:
Codex
2026-04-05 21:21:27 +05:00
parent 0376393672
commit c20f0b7a96
4 changed files with 167 additions and 95 deletions
@@ -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));
}