link_repository = $link_repository; $this->keyword_repository = $keyword_repository; $this->keyword_matcher = $keyword_matcher; $this->distribution_strategy = $distribution_strategy; $this->link_injector = $link_injector; $this->config = $config; } public function execute(int $postId): int { if (!$this->canProcess($postId)) { return 0; } $categoryId = $this->getPostCategoryId($postId); $relatedPosts = $this->getPostsInCategory($categoryId, $postId); if (empty($relatedPosts)) { return 0; } return $this->processRelatedPosts($postId, $relatedPosts); } abstract protected function canProcess(int $postId): bool; abstract protected function processRelatedPosts(int $postId, array $relatedPosts): int; protected function getKeywordsForPost(int $postId): array { return $this->keyword_repository->findByPostId($postId); } protected function filterAvailableKeywords(array $keywords, int $sourcePostId, int $targetPostId): array { $available = []; foreach ($keywords as $keyword) { $keywordId = $keyword->getId(); if ($this->link_repository->linkExists($sourcePostId, $targetPostId, $keywordId)) { continue; } if (!$this->canAddMoreLinksForKeyword($keywordId)) { continue; } $available[] = $keyword; } return $available; } protected function findKeywordMatchesForLinks( string $content, array $keywords, string $targetUrl, string $targetTitle, ?int $targetPostId = null ): array { $links = []; foreach ($keywords as $keyword) { $matches = $this->keyword_matcher->findMatches($content, $keyword->getKeyword()); if (empty($matches)) { continue; } $remainingForKeyword = $this->getRemainingLinksForKeyword($keyword->getId()); if ($remainingForKeyword <= 0) { continue; } $selectedMatches = $this->distribution_strategy->selectDistributedMatches( $matches, min($remainingForKeyword, 1), $content ); foreach ($selectedMatches as $match) { $linkData = [ 'match' => $match, 'url' => $targetUrl, 'title' => $targetTitle, 'keyword_id' => $keyword->getId(), ]; if ($targetPostId !== null) { $linkData['target_post_id'] = $targetPostId; } $links[] = $linkData; } } return $links; } protected function saveLinksToPost(int $sourcePostId, ?int $fixedTargetPostId, array $linksToAdd): int { if (empty($linksToAdd)) { return 0; } if (!$this->updatePostContentWithLinks($sourcePostId, $linksToAdd)) { return 0; } $savedCount = 0; foreach ($linksToAdd as $linkData) { $targetPostId = $fixedTargetPostId ?? $linkData['target_post_id']; if ($this->saveLinkRecord($sourcePostId, $targetPostId, $linkData['keyword_id'])) { $savedCount++; } } return $savedCount; } protected function hasReachedMaxLinksAsSource(int $postId): bool { return $this->link_repository->countBySourcePost($postId) >= $this->config->getMaxLinksPerPost(); } protected function hasReachedMaxLinksAsTarget(int $postId): bool { return $this->link_repository->countByTargetPost($postId) >= $this->config->getMaxLinksPerTarget(); } protected function getRemainingLinksForKeyword(int $keywordId): int { return $this->config->getMaxLinksPerKeyword() - $this->link_repository->countByKeyword($keywordId); } protected function canAddMoreLinksForKeyword(int $keywordId): bool { return $this->getRemainingLinksForKeyword($keywordId) > 0; } protected function getRemainingLinksForSource(int $postId): int { return $this->config->getMaxLinksPerPost() - $this->link_repository->countBySourcePost($postId); } private function getPostCategoryId(int $postId): int { $categories = wp_get_post_categories($postId); return !empty($categories) ? $categories[0] : 0; } private function getPostsInCategory(int $categoryId, int $excludePostId): array { // phpcs:disable WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_exclude -- Excluding current post from linking candidates; small result set. return get_posts([ 'category' => $categoryId, 'post_status' => 'publish', 'post_type' => 'post', 'posts_per_page' => $this->config->getInternalLinksBatchSize(), 'exclude' => [$excludePostId], ]); // phpcs:enable WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_exclude } private function updatePostContentWithLinks(int $postId, array $linksToAdd): bool { if (empty($linksToAdd)) { return true; } if (!$this->acquirePostLock($postId)) { return false; } $freshPost = get_post($postId); if (!$freshPost) { $this->releasePostLock($postId); return false; } $modifiedContent = $this->link_injector->injectLinks($freshPost->post_content, $linksToAdd); $result = wp_update_post([ 'ID' => $postId, 'post_content' => $modifiedContent, ], true); $this->releasePostLock($postId); return !is_wp_error($result); } private function acquirePostLock(int $postId, int $timeout = 5): bool { global $wpdb; $lockName = "contai_post_lock_{$postId}"; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching return $wpdb->get_var($wpdb->prepare("SELECT GET_LOCK(%s, %d)", $lockName, $timeout)) == 1; } private function releasePostLock(int $postId): bool { global $wpdb; $lockName = "contai_post_lock_{$postId}"; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching return $wpdb->get_var($wpdb->prepare("SELECT RELEASE_LOCK(%s)", $lockName)) == 1; } private function saveLinkRecord(int $sourcePostId, int $targetPostId, int $keywordId): bool { if ($this->link_repository->linkExists($sourcePostId, $targetPostId, $keywordId)) { return false; } $link = new \WPContentAI\ContaiDatabase\Models\ContaiInternalLink([ 'source_post_id' => $sourcePostId, 'target_post_id' => $targetPostId, 'keyword_id' => $keywordId, 'status' => \WPContentAI\ContaiDatabase\Models\ContaiInternalLink::STATUS_ACTIVE, ]); return $this->link_repository->create($link) !== false; } }