diff --git a/README.md b/README.md index e29bb82..754ab20 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,8 @@ A Laravel application that analyzes Amazon product reviews to detect fake reviews using AI. The service fetches reviews via the Unwrangle API, analyzes them with OpenAI, and provides authenticity scores. -Visit [nullfake.com](https://nullfake.com) to try it out. +# Visit [nullfake.com](https://nullfake.com) to try it out. +# Read our [blog post about how nullfake works](https://shift8web.ca/from-fakespot-to-null-fake-navigating-the-evolving-landscape-of-fake-reviews/) ## How It Works @@ -54,3 +55,8 @@ The model calculates: ## License MIT + +## Shift8 + +This was developed in Toronto Canada by [Shift8 Web](https://shift8web.ca) + diff --git a/app/Exceptions/ReviewServiceException.php b/app/Exceptions/ReviewServiceException.php index ac278e7..5883acf 100644 --- a/app/Exceptions/ReviewServiceException.php +++ b/app/Exceptions/ReviewServiceException.php @@ -4,4 +4,4 @@ class ReviewServiceException extends \Exception { -} \ No newline at end of file +} diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php index 77ec359..f1406be 100644 --- a/app/Http/Controllers/Controller.php +++ b/app/Http/Controllers/Controller.php @@ -8,5 +8,6 @@ class Controller extends BaseController { - use AuthorizesRequests, ValidatesRequests; + use AuthorizesRequests; + use ValidatesRequests; } diff --git a/app/Http/Controllers/UrlExpansionController.php b/app/Http/Controllers/UrlExpansionController.php new file mode 100644 index 0000000..fbe1b0f --- /dev/null +++ b/app/Http/Controllers/UrlExpansionController.php @@ -0,0 +1,148 @@ +validate([ + 'url' => 'required|url' + ]); + + $shortUrl = $request->input('url'); + + // Only expand Amazon short URLs for security + if (!$this->isAmazonShortUrl($shortUrl)) { + return response()->json([ + 'success' => false, + 'error' => 'Only Amazon URLs are supported' + ], 400); + } + + try { + LoggingService::log('Expanding short URL', [ + 'short_url' => $shortUrl + ]); + + // Follow redirects manually to get final URL + $finalUrl = $this->followRedirects($shortUrl); + + LoggingService::log('URL expansion successful', [ + 'short_url' => $shortUrl, + 'final_url' => $finalUrl + ]); + + return response()->json([ + 'success' => true, + 'original_url' => $shortUrl, + 'expanded_url' => $finalUrl + ]); + + } catch (GuzzleException $e) { + LoggingService::log('URL expansion failed', [ + 'short_url' => $shortUrl, + 'error' => $e->getMessage() + ]); + + return response()->json([ + 'success' => false, + 'error' => 'Unable to expand URL: ' . $e->getMessage() + ], 500); + } + } + + /** + * Follow redirects manually to get final URL without validating the final destination + */ + private function followRedirects(string $url, int $maxRedirects = 5): string + { + $client = new Client([ + 'timeout' => 5, + 'connect_timeout' => 2, + 'allow_redirects' => false, + 'headers' => [ + 'User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36' + ] + ]); + + $currentUrl = $url; + $redirectCount = 0; + + while ($redirectCount < $maxRedirects) { + try { + $response = $client->get($currentUrl); + $statusCode = $response->getStatusCode(); + + LoggingService::log('Redirect step', [ + 'url' => $currentUrl, + 'status' => $statusCode, + 'redirect_count' => $redirectCount + ]); + + if (in_array($statusCode, [301, 302, 303, 307, 308])) { + $locationHeaders = $response->getHeader('Location'); + if (empty($locationHeaders)) { + LoggingService::log('No location header found, stopping redirects'); + break; + } + + $newUrl = $locationHeaders[0]; + + // If we've reached an Amazon product URL, stop here - don't try to validate it + if (strpos($newUrl, 'amazon.com/dp/') !== false || strpos($newUrl, 'amazon.com/gp/product/') !== false) { + LoggingService::log('Reached Amazon product URL, stopping redirects', [ + 'final_url' => $newUrl + ]); + return $newUrl; + } + + $currentUrl = $newUrl; + $redirectCount++; + } else { + // Got a non-redirect response, stop here + break; + } + } catch (\Exception $e) { + LoggingService::log('Redirect failed', [ + 'url' => $currentUrl, + 'error' => $e->getMessage() + ]); + break; + } + } + + return $currentUrl; + } + + /** + * Check if URL is an Amazon short URL + */ + private function isAmazonShortUrl(string $url): bool + { + $amazonShortDomains = [ + 'a.co', + 'amzn.to', + 'amazon.com' + ]; + + $host = parse_url($url, PHP_URL_HOST); + + foreach ($amazonShortDomains as $domain) { + if ($host === $domain || str_ends_with($host, '.' . $domain)) { + return true; + } + } + + return false; + } +} \ No newline at end of file diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php index 494c050..d3a2c6d 100644 --- a/app/Http/Kernel.php +++ b/app/Http/Kernel.php @@ -53,16 +53,16 @@ class Kernel extends HttpKernel * @var array */ protected $middlewareAliases = [ - 'auth' => \App\Http\Middleware\Authenticate::class, - 'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class, - 'auth.session' => \Illuminate\Session\Middleware\AuthenticateSession::class, - 'cache.headers' => \Illuminate\Http\Middleware\SetCacheHeaders::class, - 'can' => \Illuminate\Auth\Middleware\Authorize::class, - 'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class, + 'auth' => \App\Http\Middleware\Authenticate::class, + 'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class, + 'auth.session' => \Illuminate\Session\Middleware\AuthenticateSession::class, + 'cache.headers' => \Illuminate\Http\Middleware\SetCacheHeaders::class, + 'can' => \Illuminate\Auth\Middleware\Authorize::class, + 'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class, 'password.confirm' => \Illuminate\Auth\Middleware\RequirePassword::class, - 'precognitive' => \Illuminate\Foundation\Http\Middleware\HandlePrecognitiveRequests::class, - 'signed' => \App\Http\Middleware\ValidateSignature::class, - 'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class, - 'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class, + 'precognitive' => \Illuminate\Foundation\Http\Middleware\HandlePrecognitiveRequests::class, + 'signed' => \App\Http\Middleware\ValidateSignature::class, + 'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class, + 'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class, ]; } diff --git a/app/Http/Middleware/RedirectIfAuthenticated.php b/app/Http/Middleware/RedirectIfAuthenticated.php index afc78c4..dae8398 100644 --- a/app/Http/Middleware/RedirectIfAuthenticated.php +++ b/app/Http/Middleware/RedirectIfAuthenticated.php @@ -13,7 +13,7 @@ class RedirectIfAuthenticated /** * Handle an incoming request. * - * @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next + * @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next */ public function handle(Request $request, Closure $next, string ...$guards): Response { diff --git a/app/Livewire/ReviewAnalyzer.php b/app/Livewire/ReviewAnalyzer.php index 84b1ead..2681939 100644 --- a/app/Livewire/ReviewAnalyzer.php +++ b/app/Livewire/ReviewAnalyzer.php @@ -2,13 +2,12 @@ namespace App\Livewire; -use Livewire\Component; -use App\Services\ReviewAnalysisService; use App\Services\CaptchaService; -use Illuminate\Support\Facades\Log; -use App\Services\Amazon\AmazonFetchService; -use App\Services\OpenAIService; use App\Services\LoggingService; +use App\Services\ReviewAnalysisService; +use Illuminate\Http\Request; +use Illuminate\Validation\ValidationException; +use Livewire\Component; /** * Livewire component for Amazon product review analysis interface. @@ -43,15 +42,15 @@ class ReviewAnalyzer extends Component // Progress tracking properties public $progressStep = 0; - public $progressSteps = [ - 1 => 'Validating product URL...', - 2 => 'Authenticating request...', - 3 => 'Checking product database...', - 4 => 'Gathering review information...', - 5 => 'Analyzing reviews with AI...', - 6 => 'Calculating final metrics...', - 7 => 'Finalizing results...' - ]; + public $progressSteps = [ + 1 => 'Validating product URL...', + 2 => 'Authenticating request...', + 3 => 'Checking product database...', + 4 => 'Gathering review information...', + 5 => 'Analyzing reviews with AI...', + 6 => 'Calculating final metrics...', + 7 => 'Finalizing results...', + ]; public $progressPercentage = 0; public $totalReviewsFound = 0; public $currentlyProcessing = ''; @@ -70,7 +69,7 @@ class ReviewAnalyzer extends Component public function mount() { $this->hcaptchaKey = uniqid(); - + // Initialize progress variables to ensure they're available in the template $this->progressStep = 0; $this->progressPercentage = 0; @@ -82,7 +81,9 @@ public function mount() public function analyze() { LoggingService::log('=== LIVEWIRE ANALYZE METHOD STARTED ==='); - + LoggingService::log('Product URL value: ' . ($this->productUrl ?? 'NULL')); + LoggingService::log('Product URL length: ' . strlen($this->productUrl ?? '')); + try { // Loading state and progress are already initialized by initializeProgress() // Just clear previous results @@ -100,6 +101,15 @@ public function analyze() $this->asinReview = null; $this->adjusted_rating = 0.00; $this->isAnalyzed = false; + + // Ensure productUrl is not empty before validation + if (empty($this->productUrl)) { + LoggingService::log('Product URL is empty before validation, attempting to get from input'); + // Try to get the value from the form if it exists + $this->productUrl = request()->input('productUrl', $this->productUrl); + } + + LoggingService::log('Final product URL before validation: ' . ($this->productUrl ?: 'EMPTY')); // Validate input $this->validate([ @@ -107,54 +117,73 @@ public function analyze() ]); // Captcha validation (if not local) - if (!app()->environment('local')) { - $captchaService = app(CaptchaService::class); - $provider = $captchaService->getProvider(); - $clientIp = request()->ip(); - - if ($provider === 'recaptcha' && !empty($this->g_recaptcha_response)) { - if (!$captchaService->verify($this->g_recaptcha_response, $clientIp)) { - throw new \Exception('Captcha verification failed. Please try again.'); - } - } elseif ($provider === 'hcaptcha' && !empty($this->h_captcha_response)) { - if (!$captchaService->verify($this->h_captcha_response, $clientIp)) { + if (!app()->environment(['local', 'testing'])) { + // Skip captcha validation if already passed in this session + if (!$this->captcha_passed) { + $captchaService = app(CaptchaService::class); + $provider = $captchaService->getProvider(); + + if ($provider === 'recaptcha' && !empty($this->g_recaptcha_response)) { + if (!$captchaService->verify($this->g_recaptcha_response)) { + throw new \Exception('Captcha verification failed. Please try again.'); + } + // Mark captcha as passed for this session + $this->captcha_passed = true; + } elseif ($provider === 'hcaptcha' && !empty($this->h_captcha_response)) { + if (!$captchaService->verify($this->h_captcha_response)) { + throw new \Exception('Captcha verification failed. Please try again.'); + } + // Mark captcha as passed for this session + $this->captcha_passed = true; + } else { throw new \Exception('Captcha verification failed. Please try again.'); } - } else { - throw new \Exception('Captcha verification failed. Please try again.'); } } $analysisService = app(ReviewAnalysisService::class); $productInfo = $analysisService->checkProductExists($this->productUrl); - + $asinData = $productInfo['asin_data']; - + // Gather reviews if needed if ($productInfo['needs_fetching']) { $asinData = $analysisService->fetchReviews( - $productInfo['asin'], - $productInfo['country'], + $productInfo['asin'], + $productInfo['country'], $productInfo['product_url'] ); } - + // Analyze with OpenAI if needed if ($productInfo['needs_openai']) { $asinData = $analysisService->analyzeWithOpenAI($asinData); } - + // Calculate final metrics and set results $analysisResult = $analysisService->calculateFinalMetrics($asinData); $this->setResults($analysisResult); - + // Set final state $this->isAnalyzed = true; LoggingService::log('=== LIVEWIRE ANALYZE METHOD COMPLETED SUCCESSFULLY ==='); - + } catch (ValidationException $e) { + // Preserve validation errors as-is since they're already user-friendly + LoggingService::log('Validation error in analyze method: ' . $e->getMessage()); + // Get the first validation error message + $errors = $e->errors(); + $this->error = !empty($errors) ? reset($errors)[0] : $e->getMessage(); + $this->resetAnalysisState(); } catch (\Exception $e) { - LoggingService::log('Exception in analyze method: ' . $e->getMessage()); + // Check if this is a CAPTCHA error and preserve the message + if (str_contains($e->getMessage(), 'Captcha') || str_contains($e->getMessage(), 'captcha')) { + LoggingService::log('CAPTCHA error in analyze method: ' . $e->getMessage()); + $this->error = $e->getMessage(); + $this->resetAnalysisState(); + return; + } + LoggingService::log('Exception in analyze method: '.$e->getMessage()); $this->error = LoggingService::handleException($e); $this->resetAnalysisState(); } @@ -172,52 +201,46 @@ private function resetAnalysisState() $this->isAnalyzed = false; } - - private function setResults($analysisResult) { - LoggingService::log('Setting results with data: ' . json_encode($analysisResult)); - + LoggingService::log('Setting results with data: '.json_encode($analysisResult)); + $this->result = $analysisResult; $this->fake_percentage = $analysisResult['fake_percentage'] ?? 0; $this->amazon_rating = $analysisResult['amazon_rating'] ?? 0; $this->adjusted_rating = (float) $analysisResult['adjusted_rating']; $this->grade = $analysisResult['grade'] ?? 'N/A'; $this->explanation = $analysisResult['explanation'] ?? null; - - LoggingService::log('Results set - amazon_rating: ' . $this->amazon_rating . ', fake_percentage: ' . $this->fake_percentage . ', adjusted_rating: ' . $this->adjusted_rating); - LoggingService::log('Adjusted rating type: ' . gettype($this->adjusted_rating) . ', value: ' . var_export($this->adjusted_rating, true)); - } - + LoggingService::log('Results set - amazon_rating: '.$this->amazon_rating.', fake_percentage: '.$this->fake_percentage.', adjusted_rating: '.$this->adjusted_rating); + LoggingService::log('Adjusted rating type: '.gettype($this->adjusted_rating).', value: '.var_export($this->adjusted_rating, true)); + } public function render() { return view('livewire.review-analyzer'); } - - public function getGradeColor() { - return match($this->grade) { - 'A' => 'text-green-600', - 'B' => 'text-yellow-600', - 'C' => 'text-orange-600', - 'D' => 'text-red-600', - 'F' => 'text-red-800', + return match ($this->grade) { + 'A' => 'text-green-600', + 'B' => 'text-yellow-600', + 'C' => 'text-orange-600', + 'D' => 'text-red-600', + 'F' => 'text-red-800', default => 'text-gray-600' }; } public function getGradeBgColor() { - return match($this->grade) { - 'A' => 'bg-green-100', - 'B' => 'bg-yellow-100', - 'C' => 'bg-orange-100', - 'D' => 'bg-red-100', - 'F' => 'bg-red-200', + return match ($this->grade) { + 'A' => 'bg-green-100', + 'B' => 'bg-yellow-100', + 'C' => 'bg-orange-100', + 'D' => 'bg-red-100', + 'F' => 'bg-red-200', default => 'bg-gray-100' }; } @@ -240,14 +263,14 @@ public function clearPreviousResults() $this->asinReview = null; $this->adjusted_rating = 0.00; $this->isAnalyzed = false; - + // Also reset progress state for clean start $this->loading = false; $this->progressStep = 0; $this->progressPercentage = 0; $this->currentlyProcessing = ''; $this->progress = 0; - + // Force Livewire to re-render immediately $this->dispatch('resultsCleared'); } @@ -263,7 +286,7 @@ public function initializeProgress() $this->currentlyProcessing = 'Starting analysis...'; $this->progress = 0; $this->isAnalyzed = false; - + LoggingService::log('=== INITIALIZE PROGRESS CALLED ==='); LoggingService::log('Progress initialized - loading: true, step: 0'); } @@ -271,13 +294,21 @@ public function initializeProgress() public function startAnalysis() { LoggingService::log('=== START ANALYSIS CALLED ==='); - - // Clear previous results + + // Clear previous results $this->clearPreviousResults(); - + + // Force sync of input values (in case wire:model.live has timing issues) + $this->dispatch('syncInputs'); + // Run the analysis (JavaScript will handle progress simulation) $this->analyze(); } - -} \ No newline at end of file + // Method to sync the URL from JavaScript if needed + public function setProductUrl($url) + { + $this->productUrl = $url; + LoggingService::log('Product URL set via JavaScript: ' . $url); + } +} diff --git a/app/Models/AsinData.php b/app/Models/AsinData.php index b1c93cb..f4706d3 100644 --- a/app/Models/AsinData.php +++ b/app/Models/AsinData.php @@ -38,7 +38,7 @@ class AsinData extends Model * @var array */ protected $casts = [ - 'reviews' => 'array', + 'reviews' => 'array', 'openai_result' => 'array', ]; @@ -52,20 +52,20 @@ public function getFakePercentageAttribute(): ?float $reviews = $this->getReviewsArray(); $openaiResultJson = is_string($this->openai_result) ? $this->openai_result : json_encode($this->openai_result); $openaiResult = json_decode($openaiResultJson, true); - + if (empty($reviews) || !$openaiResult || !isset($openaiResult['detailed_scores'])) { return null; } $totalReviews = count($reviews); $fakeCount = 0; - + foreach ($openaiResult['detailed_scores'] as $reviewId => $score) { if ($score >= 70) { $fakeCount++; } } - + return $totalReviews > 0 ? round(($fakeCount / $totalReviews) * 100, 1) : 0; } @@ -77,11 +77,11 @@ public function getFakePercentageAttribute(): ?float public function getGradeAttribute(): ?string { $fakePercentage = $this->fake_percentage; - + if ($fakePercentage === null) { return null; } - + if ($fakePercentage >= 50) { return 'F'; } elseif ($fakePercentage >= 30) { @@ -104,28 +104,28 @@ public function getExplanationAttribute(): ?string { $reviews = $this->getReviewsArray(); $fakePercentage = $this->fake_percentage; - + if (empty($reviews) || $fakePercentage === null) { return null; } - + $totalReviews = count($reviews); $fakeCount = round(($fakePercentage / 100) * $totalReviews); - + $explanation = "Analysis of {$totalReviews} reviews found {$fakeCount} potentially fake reviews ({$fakePercentage}%). "; - + if ($fakePercentage >= 50) { - $explanation .= "This product has an extremely high percentage of fake reviews. Avoid purchasing."; + $explanation .= 'This product has an extremely high percentage of fake reviews. Avoid purchasing.'; } elseif ($fakePercentage >= 30) { - $explanation .= "This product has a high percentage of fake reviews. Consider looking for alternatives."; + $explanation .= 'This product has a high percentage of fake reviews. Consider looking for alternatives.'; } elseif ($fakePercentage >= 20) { - $explanation .= "This product has moderate fake review activity. Exercise some caution."; + $explanation .= 'This product has moderate fake review activity. Exercise some caution.'; } elseif ($fakePercentage >= 10) { - $explanation .= "This product has some fake review activity but is generally trustworthy."; + $explanation .= 'This product has some fake review activity but is generally trustworthy.'; } else { - $explanation .= "This product appears to have genuine reviews with minimal fake activity."; + $explanation .= 'This product appears to have genuine reviews with minimal fake activity.'; } - + return $explanation; } @@ -137,12 +137,13 @@ public function getExplanationAttribute(): ?string public function getAmazonRatingAttribute(): float { $reviews = $this->getReviewsArray(); - + if (empty($reviews)) { return 0; } $totalRating = collect($reviews)->sum('rating'); + return round($totalRating / count($reviews), 2); } @@ -155,7 +156,7 @@ public function getAdjustedRatingAttribute(): float { $reviews = $this->getReviewsArray(); $openaiResult = is_array($this->openai_result) ? $this->openai_result : json_decode($this->openai_result ?? '[]', true); - + if (empty($reviews) || !$openaiResult || !isset($openaiResult['results'])) { return $this->amazon_rating; } @@ -166,7 +167,7 @@ public function getAdjustedRatingAttribute(): float foreach ($reviews as $index => $review) { $score = $results[$index]['score'] ?? 0; - + // Scores < 80 are considered genuine if ($score < 80) { $genuineRatingSum += $review['rating']; @@ -189,18 +190,19 @@ public function getAdjustedRatingAttribute(): float public function getReviewsArray(): array { $reviews = $this->reviews; - + // If it's already an array, return it if (is_array($reviews)) { return $reviews; } - + // If it's a string, decode it if (is_string($reviews)) { $decoded = json_decode($reviews, true); + return is_array($decoded) ? $decoded : []; } - + // Default to empty array return []; } @@ -214,8 +216,9 @@ public function isAnalyzed(): bool { // Check if we have OpenAI results $openaiResult = is_array($this->openai_result) ? $this->openai_result : json_decode($this->openai_result ?? '[]', true); - return !empty($openaiResult) && + + return !empty($openaiResult) && is_array($openaiResult) && !empty($this->getReviewsArray()); } -} \ No newline at end of file +} diff --git a/app/Models/User.php b/app/Models/User.php index 4d7f70f..7b42bef 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -10,7 +10,9 @@ class User extends Authenticatable { - use HasApiTokens, HasFactory, Notifiable; + use HasApiTokens; + use HasFactory; + use Notifiable; /** * The attributes that are mass assignable. @@ -40,6 +42,6 @@ class User extends Authenticatable */ protected $casts = [ 'email_verified_at' => 'datetime', - 'password' => 'hashed', + 'password' => 'hashed', ]; } diff --git a/app/Services/Amazon/AmazonFetchService.php b/app/Services/Amazon/AmazonFetchService.php index c17ede2..70c8fbd 100644 --- a/app/Services/Amazon/AmazonFetchService.php +++ b/app/Services/Amazon/AmazonFetchService.php @@ -3,11 +3,9 @@ namespace App\Services\Amazon; use App\Models\AsinData; -use App\Exceptions\ReviewServiceException; +use App\Services\LoggingService; use GuzzleHttp\Client; -use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Http; -use App\Services\LoggingService; /** * Service for fetching Amazon product reviews via Unwrangle API. @@ -22,88 +20,103 @@ class AmazonFetchService public function __construct() { $this->httpClient = new Client([ - 'timeout' => 30, + 'timeout' => 20, 'http_errors' => false, + 'connect_timeout' => 5, ]); } /** * Fetch Amazon reviews and save to database. * - * @param string $asin Amazon Standard Identification Number - * @param string $country Two-letter country code + * @param string $asin Amazon Standard Identification Number + * @param string $country Two-letter country code * @param string $productUrl Full Amazon product URL - * @return AsinData The created database record + * * @throws \Exception If product doesn't exist or fetching fails + * + * @return AsinData The created database record */ public function fetchReviewsAndSave(string $asin, string $country, string $productUrl): AsinData { - // Fetch reviews from Amazon - $reviewsData = $this->fetchReviews($asin, $country); - - // Check if fetching failed (empty reviews data means validation failed) + // Fetch reviews from Amazon with optimized performance + $reviewsData = $this->fetchReviewsOptimized($asin, $country); + + // Check if fetching failed and provide specific error message if (empty($reviewsData) || !isset($reviewsData['reviews'])) { - throw new \Exception('Product does not exist on Amazon.com (US) site. Please check the URL and try again.'); + // Since we're not doing server-side validation, assume it's an API issue + throw new \Exception('Unable to fetch product reviews at this time. This could be due to an invalid product URL, network issues, or the review service being temporarily unavailable. Please verify the Amazon URL and try again.'); } - + // Save to database - NO OpenAI analysis yet (will be done separately) return AsinData::create([ - 'asin' => $asin, - 'country' => $country, + 'asin' => $asin, + 'country' => $country, 'product_description' => $reviewsData['description'] ?? '', - 'reviews' => json_encode($reviewsData['reviews']), - 'openai_result' => null, // Will be populated later + 'reviews' => json_encode($reviewsData['reviews']), + 'openai_result' => null, // Will be populated later ]); } /** - * Fetch reviews from Amazon using Unwrangle API. - * - * @param string $asin Amazon Standard Identification Number - * @param string $country Two-letter country code (defaults to 'us') - * @return array Array containing reviews, description, and total count + * Optimized version of fetchReviews with improved performance */ - public function fetchReviews(string $asin, string $country = 'us'): array + public function fetchReviewsOptimized(string $asin, string $country = 'us'): array { - // Check if the ASIN exists on Amazon US before calling Unwrangle API - if (!$this->validateAsinExists($asin)) { - LoggingService::log('ASIN validation failed - product does not exist on amazon.com', [ + // Only do basic ASIN format validation - let client-side JS and Unwrangle API handle the rest + if (!$this->isValidAsinFormat($asin)) { + LoggingService::log('ASIN format validation failed', [ 'asin' => $asin, - 'url_checked' => "https://www.amazon.com/dp/{$asin}" ]); return []; } + + LoggingService::log('Server-side validation skipped - using client-side validation', [ + 'asin' => $asin, + ]); $apiKey = env('UNWRANGLE_API_KEY'); $cookie = env('UNWRANGLE_AMAZON_COOKIE'); $baseUrl = 'https://data.unwrangle.com/api/getter/'; - $maxPages = 10; - $country = 'us'; // Always use US country for session cookie match - + $maxPages = 10; // Keep original page count for comprehensive data + $country = 'us'; + $query = [ - 'platform' => 'amazon_reviews', - 'asin' => $asin, + 'platform' => 'amazon_reviews', + 'asin' => $asin, 'country_code' => $country, - 'max_pages' => $maxPages, - 'api_key' => $apiKey, - 'cookie' => $cookie, + 'max_pages' => $maxPages, + 'api_key' => $apiKey, + 'cookie' => $cookie, ]; try { - $response = $this->httpClient->request('GET', $baseUrl, ['query' => $query]); + $startTime = microtime(true); + + $response = $this->httpClient->request('GET', $baseUrl, [ + 'query' => $query, + 'timeout' => 45, // Increased timeout for 10 pages of data + 'connect_timeout' => 5, + ]); + + $endTime = microtime(true); + $duration = round(($endTime - $startTime) * 1000, 2); + $status = $response->getStatusCode(); $body = $response->getBody()->getContents(); LoggingService::log('Unwrangle API response', [ - 'status' => $status, - 'has_data' => !empty($body) + 'status' => $status, + 'has_data' => !empty($body), + 'duration_ms' => $duration, ]); if ($status !== 200) { LoggingService::log('Unwrangle API non-200 response', [ 'status' => $status, - 'body' => $body + 'body' => substr($body, 0, 500), // Limit log size ]); + return []; } @@ -111,8 +124,9 @@ public function fetchReviews(string $asin, string $country = 'us'): array if (empty($data) || empty($data['success'])) { LoggingService::log('Unwrangle API returned error', [ - 'error' => $data['error'] ?? 'Unknown error' + 'error' => $data['error'] ?? 'Unknown error', ]); + return []; } @@ -120,68 +134,47 @@ public function fetchReviews(string $asin, string $country = 'us'): array $description = $data['description'] ?? ''; $allReviews = $data['reviews'] ?? []; + // Keep all reviews for comprehensive analysis + LoggingService::log('Retrieved '.count($allReviews).' reviews for analysis'); + return [ - 'reviews' => $allReviews, - 'description' => $description, + 'reviews' => $allReviews, + 'description' => $description, 'total_reviews' => $totalReviews, ]; - } catch (\Exception $e) { LoggingService::log('Unwrangle API request exception', [ 'error' => $e->getMessage(), - 'asin' => $asin + 'asin' => $asin, ]); + return []; } } /** - * Validate that an ASIN exists on Amazon US by checking the product page. + * Fetch reviews from Amazon using Unwrangle API. * - * @param string $asin Amazon Standard Identification Number - * @return bool True if product exists (returns 200), false otherwise + * @param string $asin Amazon Standard Identification Number + * @param string $country Two-letter country code (defaults to 'us') + * + * @return array Array containing reviews, description, and total count */ - private function validateAsinExists(string $asin): bool + public function fetchReviews(string $asin, string $country = 'us'): array { - $url = "https://www.amazon.com/dp/{$asin}"; - - try { - $response = $this->httpClient->request('GET', $url, [ - 'timeout' => 10, - 'allow_redirects' => false, // Don't follow redirects to catch geo-redirects - 'headers' => [ - 'User-Agent' => 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36', - 'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8', - 'Accept-Language' => 'en-US,en;q=0.9', - 'Accept-Encoding' => 'gzip, deflate, br', - 'Cache-Control' => 'no-cache', - 'Pragma' => 'no-cache', - 'Upgrade-Insecure-Requests' => '1', - 'Sec-Fetch-Dest' => 'document', - 'Sec-Fetch-Mode' => 'navigate', - 'Sec-Fetch-Site' => 'none', - 'Sec-Fetch-User' => '?1' - ], - 'http_errors' => false // Don't throw exceptions on 4xx/5xx - ]); - - $statusCode = $response->getStatusCode(); - - LoggingService::log('ASIN validation check', [ - 'asin' => $asin, - 'url' => $url, - 'status_code' => $statusCode - ]); - - return $statusCode === 200; - - } catch (\Exception $e) { - LoggingService::log('ASIN validation failed with exception', [ - 'asin' => $asin, - 'error' => $e->getMessage() - ]); - return false; - } + // Use optimized version + return $this->fetchReviewsOptimized($asin, $country); + } + + + + /** + * Validate ASIN format without hitting Amazon servers + */ + private function isValidAsinFormat(string $asin): bool + { + // ASIN should be exactly 10 characters, alphanumeric + return preg_match('/^[A-Z0-9]{10}$/', $asin) === 1; } diff --git a/app/Services/Amazon/AmazonScrapingService.php b/app/Services/Amazon/AmazonScrapingService.php deleted file mode 100644 index e69de29..0000000 diff --git a/app/Services/CaptchaService.php b/app/Services/CaptchaService.php index 48f8538..485a53f 100644 --- a/app/Services/CaptchaService.php +++ b/app/Services/CaptchaService.php @@ -5,7 +5,7 @@ use Illuminate\Support\Facades\Http; /** - * Captcha Service for handling multiple CAPTCHA providers + * Captcha Service for handling multiple CAPTCHA providers. * * This service provides a unified interface for both reCAPTCHA and hCaptcha * verification systems, automatically using the configured provider. @@ -20,6 +20,7 @@ class CaptchaService public function getSiteKey(): string { $provider = config('captcha.provider'); + return config("captcha.{$provider}.site_key"); } @@ -36,15 +37,16 @@ public function getProvider(): string /** * Verify a captcha response token with the configured provider. * - * @param string $token The captcha response token from the client - * @param string|null $ip Optional client IP address for verification + * @param string $token The captcha response token from the client + * @param string|null $ip Optional client IP address for verification + * * @return bool True if verification successful, false otherwise */ - public function verify(string $token, string $ip = null): bool + public function verify(string $token, ?string $ip = null): bool { $provider = config('captcha.provider'); - $secret = config("captcha.{$provider}.secret_key"); - $url = config("captcha.{$provider}.verify_url"); + $secret = config("captcha.{$provider}.secret_key"); + $url = config("captcha.{$provider}.verify_url"); $response = Http::asForm()->post($url, array_filter([ 'secret' => $secret, diff --git a/app/Services/LoggingService.php b/app/Services/LoggingService.php index b33d904..070b00c 100644 --- a/app/Services/LoggingService.php +++ b/app/Services/LoggingService.php @@ -15,36 +15,44 @@ class LoggingService const ERROR_TYPES = [ 'TIMEOUT' => [ 'patterns' => ['cURL error 28', 'Operation timed out'], - 'message' => 'The request took too long to complete. Please try again.' + 'message' => 'The request took too long to complete. Please try again.', ], 'PRODUCT_NOT_FOUND' => [ 'patterns' => ['Product does not exist on Amazon.com (US) site'], - 'message' => 'Product does not exist on Amazon.com (US) site. Please check the URL and try again.' + 'message' => 'Product does not exist on Amazon.com (US) site. Please check the URL and try again.', ], 'DATA_TYPE_ERROR' => [ 'patterns' => ['count(): Argument #1 ($value) must be of type Countable|array', 'TypeError'], - 'message' => 'Data processing error occurred. Please try again.' + 'message' => 'Data processing error occurred. Please try again.', ], 'FETCHING_FAILED' => [ 'patterns' => ['Failed to fetch reviews'], - 'message' => 'Unable to fetch reviews at this time. Please try again later.' + 'message' => 'Unable to fetch reviews at this time. Please try again later.', ], 'OPENAI_ERROR' => [ 'patterns' => ['OpenAI API request failed'], - 'message' => 'Analysis service is temporarily unavailable. Please try again later.' + 'message' => 'Analysis service is temporarily unavailable. Please try again later.', ], 'INVALID_URL' => [ 'patterns' => ['Invalid Amazon URL', 'ASIN not found', 'Could not extract ASIN from URL'], - 'message' => 'Please provide a valid Amazon product URL.' + 'message' => 'Please provide a valid Amazon product URL.', ], 'REDIRECT_FAILED' => [ 'patterns' => ['Failed to follow redirect', 'Redirect does not lead to Amazon domain'], - 'message' => 'Unable to resolve the shortened URL. Please try using the full Amazon product URL instead.' - ] + 'message' => 'Unable to resolve the shortened URL. Please try using the full Amazon product URL instead.', + ], + 'VALIDATION_ERROR' => [ + 'patterns' => ['The product url field is required', 'The product url field must be a valid URL', 'validation.required', 'validation.url'], + 'message' => null, // Will use original message for validation errors + ], + 'CAPTCHA_ERROR' => [ + 'patterns' => ['Captcha verification failed', 'captcha verification failed', 'CAPTCHA', 'captcha'], + 'message' => null, // Will use original message for captcha errors + ], ]; /** - * Log a message with context + * Log a message with context. */ public static function log(string $message, array $context = [], string $level = self::INFO): void { @@ -52,7 +60,7 @@ public static function log(string $message, array $context = [], string $level = } /** - * Log an exception and return user-friendly message + * Log an exception and return user-friendly message. */ public static function handleException(\Exception $e): string { @@ -63,7 +71,8 @@ public static function handleException(\Exception $e): string foreach (self::ERROR_TYPES as $type) { foreach ($type['patterns'] as $pattern) { if (str_contains($errorMessage, $pattern)) { - return $type['message']; + // For validation errors, return the original message + return $type['message'] ?? $errorMessage; } } } @@ -73,10 +82,10 @@ public static function handleException(\Exception $e): string } /** - * Log progress updates + * Log progress updates. */ public static function logProgress(string $step, string $message): void { self::log("Progress: {$step} - {$message}"); } -} \ No newline at end of file +} diff --git a/app/Services/OpenAIService.php b/app/Services/OpenAIService.php index 03dd4f9..b5d9a88 100644 --- a/app/Services/OpenAIService.php +++ b/app/Services/OpenAIService.php @@ -4,7 +4,6 @@ use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; -use App\Services\LoggingService; class OpenAIService { @@ -15,9 +14,9 @@ class OpenAIService public function __construct() { $this->apiKey = config('services.openai.api_key') ?? ''; - $this->model = config('services.openai.model', 'gpt-4'); + $this->model = config('services.openai.model', 'gpt-4o-mini'); $this->baseUrl = config('services.openai.base_url', 'https://api.openai.com/v1'); - + if (empty($this->apiKey)) { throw new \InvalidArgumentException('OpenAI API key is not configured. Please set OPENAI_API_KEY in your .env file.'); } @@ -30,183 +29,447 @@ public function analyzeReviews(array $reviews): array } // Log the number of reviews being sent - LoggingService::log("Sending " . count($reviews) . " reviews to OpenAI for analysis"); - - $prompt = $this->buildPrompt($reviews); - + LoggingService::log('Sending '.count($reviews).' reviews to OpenAI for analysis'); + + // For better performance, process in parallel chunks if we have many reviews + $parallelThreshold = config('services.openai.parallel_threshold', 50); + if (count($reviews) > $parallelThreshold) { + LoggingService::log('Large dataset detected ('.count($reviews).' reviews), processing in parallel chunks'); + return $this->analyzeReviewsInParallelChunks($reviews); + } + + $prompt = $this->buildOptimizedPrompt($reviews); + // Log prompt size for debugging $promptSize = strlen($prompt); - LoggingService::log("Prompt size: {$promptSize} characters"); - - // Only chunk if prompt is extremely large (>100k chars) or too many reviews (>100) - if ($promptSize > 100000 || count($reviews) > 100) { - LoggingService::log("Very large payload detected, processing in chunks to avoid API limits"); - return $this->analyzeReviewsInChunks($reviews); - } + LoggingService::log("Optimized prompt size: {$promptSize} characters"); try { // Extract the endpoint from base_url if it includes the full path $endpoint = $this->baseUrl; if (!str_ends_with($endpoint, '/chat/completions')) { - $endpoint = rtrim($endpoint, '/') . '/chat/completions'; + $endpoint = rtrim($endpoint, '/').'/chat/completions'; } LoggingService::log("Making OpenAI API request to: {$endpoint}"); - // Determine max_tokens based on model - $maxTokens = $this->getMaxTokensForModel($this->model); - LoggingService::log("Using max_tokens: {$maxTokens} for model: {$this->model}"); + // Use optimized parameters for faster processing + $maxTokens = $this->getOptimizedMaxTokens(count($reviews)); + $timeout = config('services.openai.timeout', 120); + LoggingService::log("Using optimized max_tokens: {$maxTokens} for {$this->model} with ".count($reviews)." reviews, timeout: {$timeout}s"); $response = Http::withHeaders([ - 'Authorization' => 'Bearer ' . $this->apiKey, - 'Content-Type' => 'application/json', - 'User-Agent' => 'ReviewAnalyzer/1.0', - ])->timeout(300)->connectTimeout(60)->retry(3, 2000)->post($endpoint, [ - 'model' => $this->model, + 'Authorization' => 'Bearer '.$this->apiKey, + 'Content-Type' => 'application/json', + 'User-Agent' => 'ReviewAnalyzer/1.0', + ])->timeout($timeout)->connectTimeout(30)->retry(2, 1000)->post($endpoint, [ + 'model' => $this->model, 'messages' => [ [ - 'role' => 'system', - 'content' => 'You are an expert at detecting fake reviews. Analyze each review and provide a score from 0-100 where 0 is genuine and 100 is definitely fake. Return ONLY a JSON array with objects containing "id" and "score" fields.' - ], + 'role' => 'system', + 'content' => ' + + You are an expert Amazon review fraud detection specialist with 10 years of experience analyzing deceptive content patterns. You excel at identifying fake reviews through multi-dimensional analysis combining linguistic, behavioral, and contextual indicators. + + DETECTION METHODOLOGY - Analyze each review using this structured approach: + + 1. **SENTIMENT-RATING ALIGNMENT**: Check if emotional tone matches numeric rating (major red flag: 5-star rating with negative sentiment) + 2. **SPECIFICITY ASSESSMENT**: Genuine reviews include specific product details, usage timeframes, and personal context + 3. **LINGUISTIC PATTERNS**: Detect excessive enthusiasm, generic praise, redundant terms, unnatural sentence structures + 4. **BEHAVIORAL INDICATORS**: Consider reviewer history patterns, timing anomalies, verification status when available + 5. **AUTHENTICITY MARKERS**: Look for natural language flow, specific problem-solution narratives, realistic expectations + + SCORING GUIDELINES (0-100 scale): + - **0-20**: Clearly authentic (specific details, balanced tone, natural language, verified purchase patterns) + - **21-40**: Likely authentic (minor inconsistencies but genuine indicators dominate) + - **41-60**: Suspicious (mixed signals, some red flags present, requires careful analysis) + - **61-80**: Likely fake (multiple red flags, generic content, sentiment misalignment) + - **81-100**: Obviously fake (extreme language, no specifics, clear manipulation patterns) + + RED FLAG INDICATORS: + - Generic superlatives without supporting details ("best ever", "amazing", "perfect") + - Sentiment-rating misalignment (negative text with 5 stars or positive text with 1 star) + - Excessive emotional language without specific experiences + - Lack of product-specific terminology or features + - Unrealistic perfection claims or extreme negativity without context + - Repetitive phrasing or unnatural sentence structures + + EXAMPLES FOR CALIBRATION: + + Review: "Used this phone case for 3 months during my hiking trips. Dropped my phone twice on rocky terrain - no damage. The grip texture works well with gloves. Only minor issue is it adds slight bulk to wireless charging." + Analysis: Specific timeframe (3 months), concrete usage context (hiking), detailed experience (drops, gloves), balanced feedback (minor issue noted). Score: 15 + + Review: "AMAZING PRODUCT!!! Everyone needs this! Best quality ever seen! 5 stars! Highly recommend to all customers! Perfect in every way!" + Analysis: Excessive enthusiasm, generic superlatives, no specific details, unnatural repetition of praise terms, marketing-like language. Score: 85 + + Process each review independently using this methodology. Use the full 0-100 range with most products having 15-40% fake reviews in the dataset. Be appropriately suspicious while avoiding false positives on genuinely enthusiastic authentic reviews that include specific details. + + OUTPUT FORMAT: Return ONLY valid JSON array AND NOTHING ELSE: [{"id":"review_id","score":numerical_score}] + ',], [ - 'role' => 'user', - 'content' => $prompt - ] + 'role' => 'user', + 'content' => $prompt, + ], ], - 'temperature' => 0.1, // Lower for more consistent results - 'max_tokens' => $maxTokens, + 'temperature' => 0.0, // Deterministic for consistency and speed + 'max_tokens' => $maxTokens, + 'top_p' => 0.1, // More focused responses ]); if ($response->successful()) { - LoggingService::log("OpenAI API request successful"); + LoggingService::log('OpenAI API request successful'); $result = $response->json(); + return $this->parseOpenAIResponse($result, $reviews); } else { + $statusCode = $response->status(); + $responseBody = $response->body(); + Log::error('OpenAI API error', [ - 'status' => $response->status(), - 'body' => $response->body() + 'status' => $statusCode, + 'body' => $responseBody, ]); - throw new \Exception('OpenAI API request failed: ' . $response->status()); + + // Handle specific error types with user-friendly messages + $errorMessage = $this->getErrorMessage($statusCode, $responseBody); + throw new \Exception($errorMessage); } } catch (\Exception $e) { LoggingService::log('OpenAI service error', ['error' => $e->getMessage()]); - + // Check if it's a timeout with 0 bytes - this suggests connection issues if (str_contains($e->getMessage(), 'cURL error 28') && str_contains($e->getMessage(), '0 bytes received')) { throw new \Exception('Unable to connect to OpenAI service. Please check your internet connection and try again.'); } - - throw new \Exception('Failed to analyze reviews: ' . $e->getMessage()); + + // Don't re-wrap already formatted error messages + if (str_contains($e->getMessage(), 'quota') || + str_contains($e->getMessage(), 'rate limit') || + str_contains($e->getMessage(), 'temporarily unavailable')) { + throw $e; + } + + throw new \Exception('Failed to analyze reviews: '.$e->getMessage()); } } + /** + * Process reviews in parallel chunks for better performance with large datasets + */ + private function analyzeReviewsInParallelChunks(array $reviews): array + { + $chunkSize = config('services.openai.chunk_size', 25); + $chunks = array_chunk($reviews, $chunkSize); + $allDetailedScores = []; + + LoggingService::log('Processing '.count($chunks).' chunks in parallel for '.count($reviews).' reviews'); + + // Process chunks concurrently using Laravel's HTTP pool + $responses = Http::pool(function ($pool) use ($chunks) { + $endpoint = $this->baseUrl; + if (!str_ends_with($endpoint, '/chat/completions')) { + $endpoint = rtrim($endpoint, '/').'/chat/completions'; + } + + foreach ($chunks as $index => $chunk) { + $prompt = $this->buildOptimizedPrompt($chunk); + $maxTokens = $this->getOptimizedMaxTokens(count($chunk)); + + $pool->withHeaders([ + 'Authorization' => 'Bearer '.$this->apiKey, + 'Content-Type' => 'application/json', + 'User-Agent' => 'ReviewAnalyzer/1.0', + ])->timeout(60)->connectTimeout(20)->post($endpoint, [ + 'model' => $this->model, + 'messages' => [ + [ + 'role' => 'system', + 'content' => 'You are an expert Amazon review authenticity detector. Be SUSPICIOUS and thorough - most products have 15-40% fake reviews. Score 0-100 where 0=definitely genuine, 100=definitely fake. Use the full range: 20-40 for suspicious, 50-70 for likely fake, 80+ for obvious fakes. Return ONLY JSON: [{"id":"X","score":Y}]', + ], + [ + 'role' => 'user', + 'content' => $prompt, + ], + ], + 'temperature' => 0.0, + 'max_tokens' => $maxTokens, + 'top_p' => 0.1, + ]); + } + }); + + // Process all responses + foreach ($responses as $chunkIndex => $response) { + $chunk = $chunks[$chunkIndex]; + + if ($response->successful()) { + $result = $response->json(); + $chunkResult = $this->parseOpenAIResponse($result, $chunk); + + if (isset($chunkResult['detailed_scores'])) { + $chunkScores = $chunkResult['detailed_scores']; + $allDetailedScores = array_merge($allDetailedScores, $chunkScores); + LoggingService::log('Successfully processed chunk '.($chunkIndex + 1).' ('.count($chunk).' reviews) with '.count($chunkScores).' scores'); + } else { + LoggingService::log('Chunk '.($chunkIndex + 1).' returned no scores'); + } + } else { + LoggingService::log('Error processing chunk '.($chunkIndex + 1).': HTTP '.$response->status()); + // Continue with other chunks even if one fails + } + } + + LoggingService::log('Parallel processing completed with '.count($allDetailedScores).' total scores'); + + return ['detailed_scores' => $allDetailedScores]; + } + private function analyzeReviewsInChunks(array $reviews): array { - $chunkSize = 25; // Process 25 reviews at a time + $chunkSize = config('services.openai.chunk_size', 25); $chunks = array_chunk($reviews, $chunkSize); $allDetailedScores = []; - + foreach ($chunks as $index => $chunk) { - LoggingService::log("Processing chunk " . ($index + 1) . " of " . count($chunks) . " (" . count($chunk) . " reviews)"); - + LoggingService::log('Processing chunk '.($index + 1).' of '.count($chunks).' ('.count($chunk).' reviews)'); + try { $chunkResult = $this->analyzeReviews($chunk); - + if (isset($chunkResult['detailed_scores'])) { $allDetailedScores = array_merge($allDetailedScores, $chunkResult['detailed_scores']); } - + // Small delay between chunks to avoid rate limiting - usleep(500000); // 0.5 seconds - + usleep(200000); // Reduced to 0.2 seconds } catch (\Exception $e) { - LoggingService::log("Error processing chunk " . ($index + 1) . ": " . $e->getMessage()); + LoggingService::log('Error processing chunk '.($index + 1).': '.$e->getMessage()); // Continue with other chunks even if one fails } } - + return ['detailed_scores' => $allDetailedScores]; } - private function buildPrompt($reviews): string + /** + * Build an optimized prompt that uses fewer tokens while maintaining accuracy + */ + private function buildOptimizedPrompt($reviews): string { - $prompt = "Analyze these Amazon reviews for authenticity. Score each from 0-100 (0=genuine, 100=fake).\n\n"; - $prompt .= "FAKE INDICATORS: Generic language, excessive praise without specifics, promotional tone, very short 5-star reviews, missing product details, suspicious names.\n"; - $prompt .= "GENUINE INDICATORS: Specific details, balanced pros/cons, personal context, time references, ingredient mentions, detailed complaints.\n\n"; - $prompt .= "Return JSON: [{\"id\":\"X\",\"score\":Y},...]\n\n"; + $prompt = "Score each review 0-100 (0=genuine, 100=fake). Be thorough and suspicious. Return JSON: [{\"id\":\"X\",\"score\":Y}]\n\n"; + $prompt .= "HIGH FAKE RISK (70-100): Generic praise, no specifics, promotional language, perfect 5-stars with short text, non-verified purchases, obvious AI writing, repetitive phrases across reviews\n"; + $prompt .= "MEDIUM FAKE RISK (40-69): Overly positive without balance, lacks personal context, generic complaints, suspicious timing patterns, limited product knowledge\n"; + $prompt .= "LOW FAKE RISK (20-39): Some specifics but feels coached, minor inconsistencies, unusual language patterns for demographic\n"; + $prompt .= "GENUINE (0-19): Specific details, balanced pros/cons, personal context, natural language, verified purchase, realistic complaints, product knowledge\n\n"; + $prompt .= "Key: V=Verified, U=Unverified, Vine=Amazon Vine reviewer\n\n"; foreach ($reviews as $review) { - $verification = isset($review['meta_data']['verified_purchase']) && $review['meta_data']['verified_purchase'] ? 'Verified' : 'Unverified'; - $vine = isset($review['meta_data']['is_vine_voice']) && $review['meta_data']['is_vine_voice'] ? 'Vine' : 'Regular'; + $verified = isset($review['meta_data']['verified_purchase']) && $review['meta_data']['verified_purchase'] ? 'V' : 'U'; + $vine = isset($review['meta_data']['is_vine_voice']) && $review['meta_data']['is_vine_voice'] ? 'Vine' : ''; - $prompt .= "ID:{$review['id']} {$review['rating']}/5 {$verification} {$vine}\n"; - $prompt .= "Title: {$review['review_title']}\n"; - $prompt .= "Text: {$review['review_text']}\n\n"; + // Clean and truncate text to handle UTF-8 issues + $title = $this->cleanUtf8Text(substr($review['review_title'], 0, 100)); + $text = $this->cleanUtf8Text(substr($review['review_text'], 0, 400)); + + $prompt .= "ID:{$review['id']} {$review['rating']}/5 {$verified}{$vine}\n"; + $prompt .= "T: {$title}\n"; + $prompt .= "R: {$text}\n\n"; } return $prompt; } - private function parseOpenAIResponse($response, $reviews): array + /** + * Clean text to ensure valid UTF-8 encoding for JSON serialization + */ + private function cleanUtf8Text(string $text): string { - $content = $response['choices'][0]['message']['content'] ?? ''; + // Remove or replace invalid UTF-8 sequences + $text = mb_convert_encoding($text, 'UTF-8', 'UTF-8'); + + // Remove null bytes and other problematic characters + $text = str_replace(["\0", "\x1A"], '', $text); - LoggingService::log('Raw OpenAI response content: ' . json_encode(['content' => $content])); + // Ensure string is valid UTF-8 + if (!mb_check_encoding($text, 'UTF-8')) { + $text = mb_convert_encoding($text, 'UTF-8', 'auto'); + } + return trim($text); + } + + /** + * Get optimized max_tokens based on number of reviews for faster processing + */ + private function getOptimizedMaxTokens(int $reviewCount): int + { + // GPT-4o-mini needs more tokens than GPT-4-turbo for the same output + // Calculate based on expected output size: roughly 25-30 chars per review for JSON response + $baseTokens = $reviewCount * 12; // Increased from 8 to 12 for GPT-4o-mini + + // Add larger buffer for GPT-4o-mini to prevent truncation + $buffer = min(1000, $reviewCount * 5); // Increased buffer + + return $baseTokens + $buffer; + } + + private function buildPrompt($reviews): string + { + // Fallback to old method if needed + return $this->buildOptimizedPrompt($reviews); + } + + private function parseOpenAIResponse($response, $reviews): array + { + $content = $response['choices'][0]['message']['content'] ?? ''; + + LoggingService::log('Raw OpenAI response content: '.json_encode(['content' => substr($content, 0, 200).'...'])); + + // Clean the content to handle markdown formatting + $content = preg_replace('/^```json\s*/', '', $content); + $content = preg_replace('/\s*```$/', '', $content); + $content = trim($content); + // Try to extract JSON from the content if (preg_match('/\[[\s\S]*\]/', $content, $matches)) { $jsonString = $matches[0]; - + if ($jsonString) { - LoggingService::log('Extracted JSON string: ' . json_encode(['json' => substr($jsonString, 0, 100) . '...'])); - + LoggingService::log('Extracted JSON string: '.json_encode(['json' => substr($jsonString, 0, 100).'...'])); + try { + // Try to parse the complete JSON first $results = json_decode($jsonString, true); - + if (json_last_error() === JSON_ERROR_NONE && is_array($results)) { $detailedScores = []; foreach ($results as $result) { if (isset($result['id']) && isset($result['score'])) { - $detailedScores[$result['id']] = (int)$result['score']; + $detailedScores[$result['id']] = (int) $result['score']; } } - + + LoggingService::log('Successfully parsed complete JSON with '.count($detailedScores).' scores'); return [ - 'detailed_scores' => $detailedScores + 'detailed_scores' => $detailedScores, ]; } } catch (\Exception $e) { - LoggingService::log('Failed to parse OpenAI JSON response: ' . $e->getMessage()); + LoggingService::log('Failed to parse complete JSON: '.$e->getMessage()); } + + // If complete JSON parsing fails, try to parse partial JSON + LoggingService::log('Attempting to parse partial JSON response'); + return $this->parsePartialJsonResponse($jsonString, $reviews); } } - + LoggingService::log('Failed to parse OpenAI response, using fallback'); - + // Fallback: return empty detailed scores return [ - 'detailed_scores' => [] + 'detailed_scores' => [], ]; } + /** + * Parse partial JSON responses that may be truncated due to max_tokens limit + */ + private function parsePartialJsonResponse(string $jsonString, array $reviews): array + { + $detailedScores = []; + + // Fix common truncation issues + $jsonString = rtrim($jsonString, ','); + + // If the JSON doesn't end with ], try to close it + if (!str_ends_with(trim($jsonString), ']')) { + $jsonString = rtrim($jsonString, ',') . ']'; + } + + // Try parsing the fixed JSON + try { + $results = json_decode($jsonString, true); + + if (json_last_error() === JSON_ERROR_NONE && is_array($results)) { + foreach ($results as $result) { + if (isset($result['id']) && isset($result['score'])) { + $detailedScores[$result['id']] = (int) $result['score']; + } + } + + LoggingService::log('Successfully parsed partial JSON with '.count($detailedScores).' scores'); + return ['detailed_scores' => $detailedScores]; + } + } catch (\Exception $e) { + LoggingService::log('Failed to parse partial JSON: '.$e->getMessage()); + } + + // If still failing, try regex parsing individual entries + preg_match_all('/\{"id":"([^"]+)","score":(\d+)\}/', $jsonString, $matches, PREG_SET_ORDER); + + foreach ($matches as $match) { + $detailedScores[$match[1]] = (int) $match[2]; + } + + LoggingService::log('Regex parsing extracted '.count($detailedScores).' scores from partial response'); + + return ['detailed_scores' => $detailedScores]; + } + private function getMaxTokensForModel(string $model): int { // Map models to their max completion tokens $modelLimits = [ - 'gpt-4' => 4096, - 'gpt-4-0613' => 4096, - 'gpt-4-32k' => 32768, - 'gpt-4-32k-0613' => 32768, - 'gpt-4-turbo' => 4096, - 'gpt-4-turbo-preview' => 4096, - 'gpt-4-1106-preview' => 4096, - 'gpt-4-0125-preview' => 4096, - 'gpt-3.5-turbo' => 4096, - 'gpt-3.5-turbo-16k' => 16384, - 'gpt-3.5-turbo-0613' => 4096, + 'gpt-4' => 4096, + 'gpt-4-0613' => 4096, + 'gpt-4-32k' => 32768, + 'gpt-4-32k-0613' => 32768, + 'gpt-4-turbo' => 4096, + 'gpt-4-turbo-preview' => 4096, + 'gpt-4-1106-preview' => 4096, + 'gpt-4-0125-preview' => 4096, + 'gpt-3.5-turbo' => 4096, + 'gpt-3.5-turbo-16k' => 16384, + 'gpt-3.5-turbo-0613' => 4096, 'gpt-3.5-turbo-16k-0613' => 16384, ]; return $modelLimits[$model] ?? 4096; // Default to 4096 if model not found } + + /** + * Get user-friendly error message based on API response + */ + private function getErrorMessage(int $statusCode, string $responseBody): string + { + // Try to parse error details from response + $errorDetails = json_decode($responseBody, true); + $errorMessage = $errorDetails['error']['message'] ?? ''; + + switch ($statusCode) { + case 429: + if (str_contains($errorMessage, 'quota')) { + return 'OpenAI quota exceeded. Please check your billing and usage limits at https://platform.openai.com/usage'; + } + return 'OpenAI rate limit exceeded. Please try again in a few moments.'; + + case 401: + return 'OpenAI authentication failed. Please check your API key configuration.'; + + case 400: + return 'Invalid request to OpenAI API. Please contact support if this persists.'; + + case 503: + return 'OpenAI service is temporarily unavailable. Please try again later.'; + + case 500: + case 502: + case 504: + return 'OpenAI service error. Please try again in a few moments.'; + + default: + return "OpenAI API error (HTTP {$statusCode}). Please try again or contact support if this persists."; + } + } } diff --git a/app/Services/ReviewAnalysisService.php b/app/Services/ReviewAnalysisService.php index 399d4fe..ee49b89 100644 --- a/app/Services/ReviewAnalysisService.php +++ b/app/Services/ReviewAnalysisService.php @@ -4,9 +4,6 @@ use App\Models\AsinData; use App\Services\Amazon\AmazonFetchService; -use App\Services\OpenAIService; -use App\Services\LoggingService; -use GuzzleHttp\Client; class ReviewAnalysisService { @@ -31,30 +28,31 @@ private function extractAsinFromUrl($url): string $url = $this->followRedirect($url); LoggingService::log("Followed redirect to: {$url}"); } - + // Extract ASIN from various Amazon URL patterns $patterns = [ '/\/dp\/([A-Z0-9]{10})/', // /dp/ASIN - '/\/product\/([A-Z0-9]{10})/', // /product/ASIN + '/\/product\/([A-Z0-9]{10})/', // /product/ASIN '/\/product-reviews\/([A-Z0-9]{10})/', // /product-reviews/ASIN '/\/gp\/product\/([A-Z0-9]{10})/', // /gp/product/ASIN '/ASIN=([A-Z0-9]{10})/', // ASIN=ASIN parameter '/\/([A-Z0-9]{10})(?:\/|\?|$)/', // ASIN in path ]; - + foreach ($patterns as $pattern) { if (preg_match($pattern, $url, $matches)) { LoggingService::log("Extracted ASIN '{$matches[1]}' using pattern: {$pattern}"); + return $matches[1]; } } - + // If it's already just an ASIN if (preg_match('/^[A-Z0-9]{10}$/', $url)) { return $url; } - - throw new \Exception('Could not extract ASIN from URL: ' . $url); + + throw new \Exception('Could not extract ASIN from URL: '.$url); } private function followRedirect(string $url): string @@ -63,55 +61,56 @@ private function followRedirect(string $url): string if (!preg_match('/^https?:\/\/a\.co\//', $url)) { throw new \Exception('Redirect following only allowed for a.co domains'); } - + try { LoggingService::log("Following redirect for URL: {$url}"); - + // Track redirects manually using on_redirect callback $redirectUrls = []; $client = new \GuzzleHttp\Client([ - 'timeout' => 10, + 'timeout' => 10, 'allow_redirects' => [ - 'max' => 5, - 'strict' => true, - 'referer' => true, + 'max' => 5, + 'strict' => true, + 'referer' => true, 'on_redirect' => function ($request, $response, $uri) use (&$redirectUrls) { $redirectUrls[] = (string) $uri; - } + }, ], 'headers' => [ - 'User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', - 'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8', - 'Accept-Language' => 'en-US,en;q=0.5', - 'Accept-Encoding' => 'gzip, deflate', - 'Connection' => 'keep-alive', + 'User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', + 'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8', + 'Accept-Language' => 'en-US,en;q=0.5', + 'Accept-Encoding' => 'gzip, deflate', + 'Connection' => 'keep-alive', 'Upgrade-Insecure-Requests' => '1', - ] + ], ]); - + $response = $client->get($url); $finalUrl = !empty($redirectUrls) ? end($redirectUrls) : $url; - - LoggingService::log("Redirect resolved", [ - 'original_url' => $url, - 'final_url' => $finalUrl, - 'status_code' => $response->getStatusCode(), - 'redirect_chain' => $redirectUrls + + LoggingService::log('Redirect resolved', [ + 'original_url' => $url, + 'final_url' => $finalUrl, + 'status_code' => $response->getStatusCode(), + 'redirect_chain' => $redirectUrls, ]); - + // Verify the redirect goes to Amazon if (preg_match('/^https?:\/\/(?:www\.)?amazon\.(com|co\.uk|ca|com\.au|de|fr|it|es|in|co\.jp|com\.mx|com\.br|nl|sg|com\.tr|ae|sa|se|pl|eg|be)/', $finalUrl)) { return $finalUrl; } else { - throw new \Exception('Redirect does not lead to Amazon domain: ' . $finalUrl); + throw new \Exception('Redirect does not lead to Amazon domain: '.$finalUrl); } - } catch (\GuzzleHttp\Exception\RequestException $e) { - LoggingService::log("Redirect following failed: " . $e->getMessage()); - throw new \Exception('Failed to follow redirect: ' . $e->getMessage()); + LoggingService::log('Redirect following failed: '.$e->getMessage()); + + throw new \Exception('Failed to follow redirect: '.$e->getMessage()); } catch (\Exception $e) { - LoggingService::log("Redirect following failed: " . $e->getMessage()); - throw new \Exception('Failed to follow redirect: ' . $e->getMessage()); + LoggingService::log('Redirect following failed: '.$e->getMessage()); + + throw new \Exception('Failed to follow redirect: '.$e->getMessage()); } } @@ -125,55 +124,55 @@ public function analyzeProduct(string $asin, string $country = 'us'): array $asin = $this->extractAsinFromUrl($asin); $country = 'us'; // Default country $productUrl = "https://www.amazon.com/dp/{$asin}"; - + // Step 1: Check if analysis already exists in database $asinData = AsinData::where('asin', $asin)->where('country', $country)->first(); - + if (!$asinData) { - LoggingService::log('Product not found in database, starting fetch process for ASIN: ' . $asin); + LoggingService::log('Product not found in database, starting fetch process for ASIN: '.$asin); LoggingService::log('Progress: 1/4 - Gathering Amazon reviews...'); - + // Step 2: Fetch reviews from Amazon $asinData = $this->fetchService->fetchReviewsAndSave($asin, $country, $productUrl); - + if (!$asinData) { throw new \Exception("This product (ASIN: {$asin}) does not exist on Amazon US. Please verify the product URL and ensure it's available on amazon.com."); } - - LoggingService::log("Gathered " . count($asinData->getReviewsArray()) . " reviews, now sending to OpenAI for analysis"); - + + LoggingService::log('Gathered '.count($asinData->getReviewsArray()).' reviews, now sending to OpenAI for analysis'); + // Step 4: Analyze with OpenAI and update the same record $openaiResult = $this->openAIService->analyzeReviews($asinData->getReviewsArray()); - + // Step 5: Update the record with OpenAI analysis $asinData->update([ 'openai_result' => json_encode($openaiResult), - 'status' => 'completed', + 'status' => 'completed', ]); - - LoggingService::log("Updated database record with OpenAI analysis results"); + + LoggingService::log('Updated database record with OpenAI analysis results'); } else { LoggingService::log("Found existing analysis in database for ASIN: {$asin}"); - + // Check if OpenAI analysis is missing and complete it if needed if (!$asinData->openai_result) { LoggingService::log("OpenAI analysis missing, completing analysis for ASIN: {$asin}"); - + $reviews = $asinData->getReviewsArray(); if (empty($reviews)) { throw new \Exception('No reviews found in database for analysis'); } - + LoggingService::logProgress('Completing analysis', 'Analyzing reviews with OpenAI...'); $openaiResult = $this->openAIService->analyzeReviews($reviews); - + $asinData->update([ 'openai_result' => json_encode($openaiResult), - 'status' => 'completed', + 'status' => 'completed', ]); - + LoggingService::log("Completed missing OpenAI analysis for ASIN: {$asin}"); - + // Refresh the model to get updated data $asinData = $asinData->fresh(); } @@ -193,56 +192,56 @@ public function analyzeProduct(string $asin, string $country = 'us'): array if (is_string($openaiResult)) { $openaiResult = json_decode($openaiResult, true); } - + $detailedScores = $openaiResult['detailed_scores'] ?? []; $reviews = $asinData->getReviewsArray(); - + $totalReviews = count($reviews); $fakeCount = 0; $amazonRatingSum = 0; $genuineRatingSum = 0; $genuineCount = 0; - - LoggingService::log("=== STARTING CALCULATION DEBUG ==="); + + LoggingService::log('=== STARTING CALCULATION DEBUG ==='); LoggingService::log("Total reviews found: {$totalReviews}"); - LoggingService::log("Detailed scores count: " . count($detailedScores)); - + LoggingService::log('Detailed scores count: '.count($detailedScores)); + $fakeReviews = []; $genuineReviews = []; - + foreach ($reviews as $review) { $reviewId = $review['id']; $rating = $review['rating']; $amazonRatingSum += $rating; - + $fakeScore = $detailedScores[$reviewId] ?? 0; - + if ($fakeScore >= 70) { $fakeCount++; $fakeReviews[] = [ - 'id' => $reviewId, - 'rating' => $rating, - 'fake_score' => $fakeScore + 'id' => $reviewId, + 'rating' => $rating, + 'fake_score' => $fakeScore, ]; LoggingService::log("FAKE REVIEW: ID={$reviewId}, Rating={$rating}, Score={$fakeScore}"); } else { $genuineRatingSum += $rating; $genuineCount++; $genuineReviews[] = [ - 'id' => $reviewId, - 'rating' => $rating, - 'fake_score' => $fakeScore + 'id' => $reviewId, + 'rating' => $rating, + 'fake_score' => $fakeScore, ]; } } - - LoggingService::log("=== FAKE REVIEWS SUMMARY ==="); + + LoggingService::log('=== FAKE REVIEWS SUMMARY ==='); LoggingService::log("Total fake reviews: {$fakeCount}"); foreach ($fakeReviews as $fake) { LoggingService::log("Fake: {$fake['id']} - Rating: {$fake['rating']} - Score: {$fake['fake_score']}"); } - - LoggingService::log("=== GENUINE REVIEWS SUMMARY ==="); + + LoggingService::log('=== GENUINE REVIEWS SUMMARY ==='); LoggingService::log("Total genuine reviews: {$genuineCount}"); $genuineRatingCounts = [1 => 0, 2 => 0, 3 => 0, 4 => 0, 5 => 0]; foreach ($genuineReviews as $genuine) { @@ -251,46 +250,45 @@ public function analyzeProduct(string $asin, string $country = 'us'): array foreach ($genuineRatingCounts as $rating => $count) { LoggingService::log("Genuine {$rating}-star reviews: {$count}"); } - + $fakePercentage = $totalReviews > 0 ? ($fakeCount / $totalReviews) * 100 : 0; $amazonRating = $totalReviews > 0 ? $amazonRatingSum / $totalReviews : 0; $adjustedRating = $this->calculateAdjustedRating($genuineReviews); - - LoggingService::log("=== FINAL CALCULATIONS ==="); + + LoggingService::log('=== FINAL CALCULATIONS ==='); LoggingService::log("Amazon rating sum: {$amazonRatingSum}"); LoggingService::log("Amazon rating average: {$amazonRating}"); LoggingService::log("Genuine rating sum: {$genuineRatingSum}"); LoggingService::log("Genuine rating average: {$adjustedRating}"); LoggingService::log("Fake percentage: {$fakePercentage}%"); - + $grade = $this->calculateGrade($fakePercentage); $explanation = $this->generateExplanation($totalReviews, $fakeCount, $fakePercentage); - + // Update the database with calculated values $asinData->update([ 'fake_percentage' => round($fakePercentage, 1), - 'amazon_rating' => round($amazonRating, 2), + 'amazon_rating' => round($amazonRating, 2), 'adjusted_rating' => round($adjustedRating, 2), - 'grade' => $grade, - 'explanation' => $explanation, + 'grade' => $grade, + 'explanation' => $explanation, ]); return [ 'fake_percentage' => round($fakePercentage, 1), - 'amazon_rating' => round($amazonRating, 2), + 'amazon_rating' => round($amazonRating, 2), 'adjusted_rating' => round($adjustedRating, 2), - 'grade' => $grade, - 'explanation' => $explanation, - 'asin_review' => $asinData->fresh(), + 'grade' => $grade, + 'explanation' => $explanation, + 'asin_review' => $asinData->fresh(), ]; } catch (\Exception $e) { $userMessage = LoggingService::handleException($e); + throw new \Exception($userMessage); } } - - private function calculateGrade($fakePercentage): string { if ($fakePercentage <= 10) { @@ -308,104 +306,96 @@ private function calculateGrade($fakePercentage): string private function generateExplanation($totalReviews, $fakeCount, $fakePercentage): string { - return "Analysis of {$totalReviews} reviews found {$fakeCount} potentially fake reviews (" . round($fakePercentage, 1) . "%). " . - ($fakePercentage <= 10 ? "This product has very low fake review activity and appears highly trustworthy." : - ($fakePercentage <= 20 ? "This product has low fake review activity and appears trustworthy." : - ($fakePercentage <= 35 ? "This product has moderate fake review activity. Exercise some caution." : - ($fakePercentage <= 50 ? "This product has high fake review activity. Exercise caution when purchasing." : - "This product has very high fake review activity. We recommend avoiding this product.")))); + return "Analysis of {$totalReviews} reviews found {$fakeCount} potentially fake reviews (".round($fakePercentage, 1).'%). '. + ($fakePercentage <= 10 ? 'This product has very low fake review activity and appears highly trustworthy.' : + ($fakePercentage <= 20 ? 'This product has low fake review activity and appears trustworthy.' : + ($fakePercentage <= 35 ? 'This product has moderate fake review activity. Exercise some caution.' : + ($fakePercentage <= 50 ? 'This product has high fake review activity. Exercise caution when purchasing.' : + 'This product has very high fake review activity. We recommend avoiding this product.')))); } - private function calculateAdjustedRating($genuineReviews) + /** + * Calculate adjusted rating based on genuine reviews only. + */ + private function calculateAdjustedRating(array $genuineReviews): float { - LoggingService::log('calculateAdjustedRating called with ' . count($genuineReviews) . ' genuine reviews'); - if (empty($genuineReviews)) { - LoggingService::log('No genuine reviews found, returning 0'); return 0; } - - $totalRating = 0; - foreach ($genuineReviews as $review) { - $totalRating += $review['rating']; - LoggingService::log('Adding rating: ' . $review['rating'] . ', total so far: ' . $totalRating); - } - + + $totalRating = array_sum(array_column($genuineReviews, 'rating')); $adjustedRating = $totalRating / count($genuineReviews); - $roundedRating = round($adjustedRating, 2); - - LoggingService::log('Final calculation: ' . $totalRating . ' / ' . count($genuineReviews) . ' = ' . $adjustedRating); - LoggingService::log('Rounded result: ' . $roundedRating); - - return $roundedRating; + + return round($adjustedRating, 2); } /** - * Phase 1: Extract ASIN and check if product exists in database + * Phase 1: Extract ASIN and check if product exists in database. */ public function checkProductExists(string $productUrl): array { $asin = $this->extractAsinFromUrl($productUrl); $country = 'us'; $productUrl = "https://www.amazon.com/dp/{$asin}"; - + $asinData = AsinData::where('asin', $asin)->where('country', $country)->first(); - + return [ - 'asin' => $asin, - 'country' => $country, - 'product_url' => $productUrl, - 'exists' => $asinData !== null, - 'asin_data' => $asinData, + 'asin' => $asin, + 'country' => $country, + 'product_url' => $productUrl, + 'exists' => $asinData !== null, + 'asin_data' => $asinData, 'needs_fetching' => $asinData === null, - 'needs_openai' => $asinData === null || !$asinData->openai_result + 'needs_openai' => $asinData === null || !$asinData->openai_result, ]; } - + /** - * Phase 2: Fetch reviews from Amazon (if needed) + * Phase 2: Fetch reviews from Amazon (if needed). */ public function fetchReviews(string $asin, string $country, string $productUrl): AsinData { LoggingService::log("Starting scrape process for ASIN: {$asin}"); - + $asinData = $this->fetchService->fetchReviewsAndSave($asin, $country, $productUrl); - + if (!$asinData) { throw new \Exception("This product (ASIN: {$asin}) does not exist on Amazon US. Please verify the product URL and ensure it's available on amazon.com."); } - - LoggingService::log("Gathered " . count($asinData->getReviewsArray()) . " reviews"); + + LoggingService::log('Gathered '.count($asinData->getReviewsArray()).' reviews'); + return $asinData; } - + /** - * Phase 3: Analyze reviews with OpenAI (if needed) + * Phase 3: Analyze reviews with OpenAI (if needed). */ public function analyzeWithOpenAI(AsinData $asinData): AsinData { $reviews = $asinData->getReviewsArray(); - + if (empty($reviews)) { throw new \Exception('No reviews found for analysis'); } - - LoggingService::log("Sending " . count($reviews) . " reviews to OpenAI for analysis"); - + + LoggingService::log('Sending '.count($reviews).' reviews to OpenAI for analysis'); + $openaiResult = $this->openAIService->analyzeReviews($reviews); - + $asinData->update([ 'openai_result' => json_encode($openaiResult), - 'status' => 'completed', + 'status' => 'completed', ]); - - LoggingService::log("Updated database record with OpenAI analysis results"); - + + LoggingService::log('Updated database record with OpenAI analysis results'); + return $asinData->fresh(); } - + /** - * Phase 4: Calculate final metrics and grades + * Phase 4: Calculate final metrics and grades. */ public function calculateFinalMetrics(AsinData $asinData): array { @@ -418,95 +408,83 @@ public function calculateFinalMetrics(AsinData $asinData): array if (is_string($openaiResult)) { $openaiResult = json_decode($openaiResult, true); } - + $detailedScores = $openaiResult['detailed_scores'] ?? []; $reviews = $asinData->getReviewsArray(); - + $totalReviews = count($reviews); $fakeCount = 0; $amazonRatingSum = 0; - $genuineRatingSum = 0; - $genuineCount = 0; - - LoggingService::log("=== STARTING CALCULATION DEBUG ==="); - LoggingService::log("Total reviews found: {$totalReviews}"); - LoggingService::log("Detailed scores count: " . count($detailedScores)); - - $fakeReviews = []; $genuineReviews = []; - + + LoggingService::log('=== STARTING CALCULATION DEBUG ==='); + LoggingService::log("Total reviews found: {$totalReviews}"); + LoggingService::log('Detailed scores count: '.count($detailedScores)); + + // Optimized single-pass calculation foreach ($reviews as $review) { $reviewId = $review['id']; $rating = $review['rating']; $amazonRatingSum += $rating; - + $fakeScore = $detailedScores[$reviewId] ?? 0; - + if ($fakeScore >= 70) { $fakeCount++; - $fakeReviews[] = [ - 'id' => $reviewId, - 'rating' => $rating, - 'fake_score' => $fakeScore - ]; - LoggingService::log("FAKE REVIEW: ID={$reviewId}, Rating={$rating}, Score={$fakeScore}"); + if ($fakeCount <= 8) { // Only log first 8 fake reviews to reduce log spam + LoggingService::log("FAKE REVIEW: ID={$reviewId}, Rating={$rating}, Score={$fakeScore}"); + } } else { - $genuineRatingSum += $rating; - $genuineCount++; $genuineReviews[] = [ - 'id' => $reviewId, - 'rating' => $rating, - 'fake_score' => $fakeScore + 'id' => $reviewId, + 'rating' => $rating, + 'fake_score' => $fakeScore, ]; } } - - LoggingService::log("=== FAKE REVIEWS SUMMARY ==="); + + LoggingService::log('=== FAKE REVIEWS SUMMARY ==='); LoggingService::log("Total fake reviews: {$fakeCount}"); - foreach ($fakeReviews as $fake) { - LoggingService::log("Fake: {$fake['id']} - Rating: {$fake['rating']} - Score: {$fake['fake_score']}"); - } + + LoggingService::log('=== GENUINE REVIEWS SUMMARY ==='); + LoggingService::log("Total genuine reviews: ".count($genuineReviews)); - LoggingService::log("=== GENUINE REVIEWS SUMMARY ==="); - LoggingService::log("Total genuine reviews: {$genuineCount}"); - $genuineRatingCounts = [1 => 0, 2 => 0, 3 => 0, 4 => 0, 5 => 0]; - foreach ($genuineReviews as $genuine) { - $genuineRatingCounts[$genuine['rating']]++; - } - foreach ($genuineRatingCounts as $rating => $count) { + // Optimized rating count calculation + $genuineRatingCounts = array_count_values(array_column($genuineReviews, 'rating')); + for ($rating = 1; $rating <= 5; $rating++) { + $count = $genuineRatingCounts[$rating] ?? 0; LoggingService::log("Genuine {$rating}-star reviews: {$count}"); } - + $fakePercentage = $totalReviews > 0 ? ($fakeCount / $totalReviews) * 100 : 0; $amazonRating = $totalReviews > 0 ? $amazonRatingSum / $totalReviews : 0; $adjustedRating = $this->calculateAdjustedRating($genuineReviews); - - LoggingService::log("=== FINAL CALCULATIONS ==="); + + LoggingService::log('=== FINAL CALCULATIONS ==='); LoggingService::log("Amazon rating sum: {$amazonRatingSum}"); LoggingService::log("Amazon rating average: {$amazonRating}"); - LoggingService::log("Genuine rating sum: {$genuineRatingSum}"); LoggingService::log("Genuine rating average: {$adjustedRating}"); LoggingService::log("Fake percentage: {$fakePercentage}%"); - + $grade = $this->calculateGrade($fakePercentage); $explanation = $this->generateExplanation($totalReviews, $fakeCount, $fakePercentage); - + // Update the database with calculated values $asinData->update([ 'fake_percentage' => round($fakePercentage, 1), - 'amazon_rating' => round($amazonRating, 2), + 'amazon_rating' => round($amazonRating, 2), 'adjusted_rating' => round($adjustedRating, 2), - 'grade' => $grade, - 'explanation' => $explanation, + 'grade' => $grade, + 'explanation' => $explanation, ]); return [ 'fake_percentage' => round($fakePercentage, 1), - 'amazon_rating' => round($amazonRating, 2), + 'amazon_rating' => round($amazonRating, 2), 'adjusted_rating' => round($adjustedRating, 2), - 'grade' => $grade, - 'explanation' => $explanation, - 'asin_review' => $asinData->fresh(), + 'grade' => $grade, + 'explanation' => $explanation, + 'asin_review' => $asinData->fresh(), ]; } } diff --git a/app/Services/ReviewService.php b/app/Services/ReviewService.php index 75e2457..169cb6b 100644 --- a/app/Services/ReviewService.php +++ b/app/Services/ReviewService.php @@ -5,7 +5,7 @@ use App\Models\AsinData; /** - * Review Service for Amazon product URL processing + * Review Service for Amazon product URL processing. * * This service handles the extraction of ASINs from Amazon URLs, * country detection, and database lookups for existing analyses. @@ -16,8 +16,10 @@ class ReviewService * Extract the 10-character ASIN from the Amazon product URL. * * @param string $url The Amazon product URL - * @return string The extracted ASIN + * * @throws \InvalidArgumentException If ASIN cannot be extracted + * + * @return string The extracted ASIN */ public function extractAsin(string $url): string { @@ -27,6 +29,7 @@ public function extractAsin(string $url): string if (preg_match('/\/product\/([A-Z0-9]{10})/', $url, $matches)) { return $matches[1]; } + throw new \InvalidArgumentException('Invalid Amazon product URL'); } @@ -34,12 +37,13 @@ public function extractAsin(string $url): string * Extract country code from Amazon URL based on domain. * * @param string $url The Amazon product URL + * * @return string Two-letter country code (defaults to 'us') */ public function extractCountryFromUrl(string $url): string { $host = parse_url($url, PHP_URL_HOST); - + $countryMap = [ 'amazon.com' => 'us', 'amazon.co.uk' => 'gb', @@ -76,8 +80,9 @@ public function extractCountryFromUrl(string $url): string /** * Find existing analysis in database by ASIN and country. * - * @param string $asin The Amazon Standard Identification Number + * @param string $asin The Amazon Standard Identification Number * @param string $country Two-letter country code + * * @return AsinData|null The existing analysis or null if not found */ public function findExistingAnalysis(string $asin, string $country): ?AsinData diff --git a/config/auth.php b/config/auth.php index 9548c15..b2850bc 100644 --- a/config/auth.php +++ b/config/auth.php @@ -14,7 +14,7 @@ */ 'defaults' => [ - 'guard' => 'web', + 'guard' => 'web', 'passwords' => 'users', ], @@ -37,7 +37,7 @@ 'guards' => [ 'web' => [ - 'driver' => 'session', + 'driver' => 'session', 'provider' => 'users', ], ], @@ -62,7 +62,7 @@ 'providers' => [ 'users' => [ 'driver' => 'eloquent', - 'model' => App\Models\User::class, + 'model' => App\Models\User::class, ], // 'users' => [ @@ -93,8 +93,8 @@ 'passwords' => [ 'users' => [ 'provider' => 'users', - 'table' => 'password_reset_tokens', - 'expire' => 60, + 'table' => 'password_reset_tokens', + 'expire' => 60, 'throttle' => 60, ], ], diff --git a/config/broadcasting.php b/config/broadcasting.php index 2410485..5a79816 100644 --- a/config/broadcasting.php +++ b/config/broadcasting.php @@ -31,17 +31,17 @@ 'connections' => [ 'pusher' => [ - 'driver' => 'pusher', - 'key' => env('PUSHER_APP_KEY'), - 'secret' => env('PUSHER_APP_SECRET'), - 'app_id' => env('PUSHER_APP_ID'), + 'driver' => 'pusher', + 'key' => env('PUSHER_APP_KEY'), + 'secret' => env('PUSHER_APP_SECRET'), + 'app_id' => env('PUSHER_APP_ID'), 'options' => [ - 'cluster' => env('PUSHER_APP_CLUSTER'), - 'host' => env('PUSHER_HOST') ?: 'api-'.env('PUSHER_APP_CLUSTER', 'mt1').'.pusher.com', - 'port' => env('PUSHER_PORT', 443), - 'scheme' => env('PUSHER_SCHEME', 'https'), + 'cluster' => env('PUSHER_APP_CLUSTER'), + 'host' => env('PUSHER_HOST') ?: 'api-'.env('PUSHER_APP_CLUSTER', 'mt1').'.pusher.com', + 'port' => env('PUSHER_PORT', 443), + 'scheme' => env('PUSHER_SCHEME', 'https'), 'encrypted' => true, - 'useTLS' => env('PUSHER_SCHEME', 'https') === 'https', + 'useTLS' => env('PUSHER_SCHEME', 'https') === 'https', ], 'client_options' => [ // Guzzle client options: https://docs.guzzlephp.org/en/stable/request-options.html @@ -50,11 +50,11 @@ 'ably' => [ 'driver' => 'ably', - 'key' => env('ABLY_KEY'), + 'key' => env('ABLY_KEY'), ], 'redis' => [ - 'driver' => 'redis', + 'driver' => 'redis', 'connection' => 'default', ], diff --git a/config/cache.php b/config/cache.php index d4171e2..f48ce85 100644 --- a/config/cache.php +++ b/config/cache.php @@ -38,27 +38,27 @@ ], 'array' => [ - 'driver' => 'array', + 'driver' => 'array', 'serialize' => false, ], 'database' => [ - 'driver' => 'database', - 'table' => 'cache', - 'connection' => null, + 'driver' => 'database', + 'table' => 'cache', + 'connection' => null, 'lock_connection' => null, ], 'file' => [ - 'driver' => 'file', - 'path' => storage_path('framework/cache/data'), + 'driver' => 'file', + 'path' => storage_path('framework/cache/data'), 'lock_path' => storage_path('framework/cache/data'), ], 'memcached' => [ - 'driver' => 'memcached', + 'driver' => 'memcached', 'persistent_id' => env('MEMCACHED_PERSISTENT_ID'), - 'sasl' => [ + 'sasl' => [ env('MEMCACHED_USERNAME'), env('MEMCACHED_PASSWORD'), ], @@ -67,25 +67,25 @@ ], 'servers' => [ [ - 'host' => env('MEMCACHED_HOST', '127.0.0.1'), - 'port' => env('MEMCACHED_PORT', 11211), + 'host' => env('MEMCACHED_HOST', '127.0.0.1'), + 'port' => env('MEMCACHED_PORT', 11211), 'weight' => 100, ], ], ], 'redis' => [ - 'driver' => 'redis', - 'connection' => 'cache', + 'driver' => 'redis', + 'connection' => 'cache', 'lock_connection' => 'default', ], 'dynamodb' => [ - 'driver' => 'dynamodb', - 'key' => env('AWS_ACCESS_KEY_ID'), - 'secret' => env('AWS_SECRET_ACCESS_KEY'), - 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), - 'table' => env('DYNAMODB_CACHE_TABLE', 'cache'), + 'driver' => 'dynamodb', + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), + 'table' => env('DYNAMODB_CACHE_TABLE', 'cache'), 'endpoint' => env('DYNAMODB_ENDPOINT'), ], diff --git a/config/database.php b/config/database.php index 137ad18..b5e3797 100644 --- a/config/database.php +++ b/config/database.php @@ -36,58 +36,58 @@ 'connections' => [ 'sqlite' => [ - 'driver' => 'sqlite', - 'url' => env('DATABASE_URL'), - 'database' => env('DB_DATABASE', database_path('database.sqlite')), - 'prefix' => '', + 'driver' => 'sqlite', + 'url' => env('DATABASE_URL'), + 'database' => env('DB_DATABASE', database_path('database.sqlite')), + 'prefix' => '', 'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true), ], 'mysql' => [ - 'driver' => 'mysql', - 'url' => env('DATABASE_URL'), - 'host' => env('DB_HOST', '127.0.0.1'), - 'port' => env('DB_PORT', '3306'), - 'database' => env('DB_DATABASE', 'forge'), - 'username' => env('DB_USERNAME', 'forge'), - 'password' => env('DB_PASSWORD', ''), - 'unix_socket' => env('DB_SOCKET', ''), - 'charset' => 'utf8mb4', - 'collation' => 'utf8mb4_unicode_ci', - 'prefix' => '', + 'driver' => 'mysql', + 'url' => env('DATABASE_URL'), + 'host' => env('DB_HOST', '127.0.0.1'), + 'port' => env('DB_PORT', '3306'), + 'database' => env('DB_DATABASE', 'forge'), + 'username' => env('DB_USERNAME', 'forge'), + 'password' => env('DB_PASSWORD', ''), + 'unix_socket' => env('DB_SOCKET', ''), + 'charset' => 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', + 'prefix' => '', 'prefix_indexes' => true, - 'strict' => true, - 'engine' => null, - 'options' => extension_loaded('pdo_mysql') ? array_filter([ + 'strict' => true, + 'engine' => null, + 'options' => extension_loaded('pdo_mysql') ? array_filter([ PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'), ]) : [], ], 'pgsql' => [ - 'driver' => 'pgsql', - 'url' => env('DATABASE_URL'), - 'host' => env('DB_HOST', '127.0.0.1'), - 'port' => env('DB_PORT', '5432'), - 'database' => env('DB_DATABASE', 'forge'), - 'username' => env('DB_USERNAME', 'forge'), - 'password' => env('DB_PASSWORD', ''), - 'charset' => 'utf8', - 'prefix' => '', + 'driver' => 'pgsql', + 'url' => env('DATABASE_URL'), + 'host' => env('DB_HOST', '127.0.0.1'), + 'port' => env('DB_PORT', '5432'), + 'database' => env('DB_DATABASE', 'forge'), + 'username' => env('DB_USERNAME', 'forge'), + 'password' => env('DB_PASSWORD', ''), + 'charset' => 'utf8', + 'prefix' => '', 'prefix_indexes' => true, - 'search_path' => 'public', - 'sslmode' => 'prefer', + 'search_path' => 'public', + 'sslmode' => 'prefer', ], 'sqlsrv' => [ - 'driver' => 'sqlsrv', - 'url' => env('DATABASE_URL'), - 'host' => env('DB_HOST', 'localhost'), - 'port' => env('DB_PORT', '1433'), - 'database' => env('DB_DATABASE', 'forge'), - 'username' => env('DB_USERNAME', 'forge'), - 'password' => env('DB_PASSWORD', ''), - 'charset' => 'utf8', - 'prefix' => '', + 'driver' => 'sqlsrv', + 'url' => env('DATABASE_URL'), + 'host' => env('DB_HOST', 'localhost'), + 'port' => env('DB_PORT', '1433'), + 'database' => env('DB_DATABASE', 'forge'), + 'username' => env('DB_USERNAME', 'forge'), + 'password' => env('DB_PASSWORD', ''), + 'charset' => 'utf8', + 'prefix' => '', 'prefix_indexes' => true, // 'encrypt' => env('DB_ENCRYPT', 'yes'), // 'trust_server_certificate' => env('DB_TRUST_SERVER_CERTIFICATE', 'false'), @@ -125,24 +125,24 @@ 'options' => [ 'cluster' => env('REDIS_CLUSTER', 'redis'), - 'prefix' => env('REDIS_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_').'_database_'), + 'prefix' => env('REDIS_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_').'_database_'), ], 'default' => [ - 'url' => env('REDIS_URL'), - 'host' => env('REDIS_HOST', '127.0.0.1'), + 'url' => env('REDIS_URL'), + 'host' => env('REDIS_HOST', '127.0.0.1'), 'username' => env('REDIS_USERNAME'), 'password' => env('REDIS_PASSWORD'), - 'port' => env('REDIS_PORT', '6379'), + 'port' => env('REDIS_PORT', '6379'), 'database' => env('REDIS_DB', '0'), ], 'cache' => [ - 'url' => env('REDIS_URL'), - 'host' => env('REDIS_HOST', '127.0.0.1'), + 'url' => env('REDIS_URL'), + 'host' => env('REDIS_HOST', '127.0.0.1'), 'username' => env('REDIS_USERNAME'), 'password' => env('REDIS_PASSWORD'), - 'port' => env('REDIS_PORT', '6379'), + 'port' => env('REDIS_PORT', '6379'), 'database' => env('REDIS_CACHE_DB', '1'), ], diff --git a/config/filesystems.php b/config/filesystems.php index e9d9dbd..416c147 100644 --- a/config/filesystems.php +++ b/config/filesystems.php @@ -32,28 +32,28 @@ 'local' => [ 'driver' => 'local', - 'root' => storage_path('app'), - 'throw' => false, + 'root' => storage_path('app'), + 'throw' => false, ], 'public' => [ - 'driver' => 'local', - 'root' => storage_path('app/public'), - 'url' => env('APP_URL').'/storage', + 'driver' => 'local', + 'root' => storage_path('app/public'), + 'url' => env('APP_URL').'/storage', 'visibility' => 'public', - 'throw' => false, + 'throw' => false, ], 's3' => [ - 'driver' => 's3', - 'key' => env('AWS_ACCESS_KEY_ID'), - 'secret' => env('AWS_SECRET_ACCESS_KEY'), - 'region' => env('AWS_DEFAULT_REGION'), - 'bucket' => env('AWS_BUCKET'), - 'url' => env('AWS_URL'), - 'endpoint' => env('AWS_ENDPOINT'), + 'driver' => 's3', + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'region' => env('AWS_DEFAULT_REGION'), + 'bucket' => env('AWS_BUCKET'), + 'url' => env('AWS_URL'), + 'endpoint' => env('AWS_ENDPOINT'), 'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false), - 'throw' => false, + 'throw' => false, ], ], diff --git a/config/hashing.php b/config/hashing.php index 0e8a0bb..7629086 100644 --- a/config/hashing.php +++ b/config/hashing.php @@ -45,10 +45,10 @@ */ 'argon' => [ - 'memory' => 65536, + 'memory' => 65536, 'threads' => 1, - 'time' => 4, - 'verify' => true, + 'time' => 4, + 'verify' => true, ], ]; diff --git a/config/logging.php b/config/logging.php index c44d276..7e528e2 100644 --- a/config/logging.php +++ b/config/logging.php @@ -33,7 +33,7 @@ 'deprecations' => [ 'channel' => env('LOG_DEPRECATIONS_CHANNEL', 'null'), - 'trace' => false, + 'trace' => false, ], /* @@ -53,73 +53,73 @@ 'channels' => [ 'stack' => [ - 'driver' => 'stack', - 'channels' => ['single'], + 'driver' => 'stack', + 'channels' => ['single'], 'ignore_exceptions' => false, ], 'single' => [ - 'driver' => 'single', - 'path' => storage_path('logs/laravel.log'), - 'level' => env('LOG_LEVEL', 'debug'), + 'driver' => 'single', + 'path' => storage_path('logs/laravel.log'), + 'level' => env('LOG_LEVEL', 'debug'), 'replace_placeholders' => true, ], 'daily' => [ - 'driver' => 'daily', - 'path' => storage_path('logs/laravel.log'), - 'level' => env('LOG_LEVEL', 'debug'), - 'days' => 14, + 'driver' => 'daily', + 'path' => storage_path('logs/laravel.log'), + 'level' => env('LOG_LEVEL', 'debug'), + 'days' => 14, 'replace_placeholders' => true, ], 'slack' => [ - 'driver' => 'slack', - 'url' => env('LOG_SLACK_WEBHOOK_URL'), - 'username' => 'Laravel Log', - 'emoji' => ':boom:', - 'level' => env('LOG_LEVEL', 'critical'), + 'driver' => 'slack', + 'url' => env('LOG_SLACK_WEBHOOK_URL'), + 'username' => 'Laravel Log', + 'emoji' => ':boom:', + 'level' => env('LOG_LEVEL', 'critical'), 'replace_placeholders' => true, ], 'papertrail' => [ - 'driver' => 'monolog', - 'level' => env('LOG_LEVEL', 'debug'), - 'handler' => env('LOG_PAPERTRAIL_HANDLER', SyslogUdpHandler::class), + 'driver' => 'monolog', + 'level' => env('LOG_LEVEL', 'debug'), + 'handler' => env('LOG_PAPERTRAIL_HANDLER', SyslogUdpHandler::class), 'handler_with' => [ - 'host' => env('PAPERTRAIL_URL'), - 'port' => env('PAPERTRAIL_PORT'), + 'host' => env('PAPERTRAIL_URL'), + 'port' => env('PAPERTRAIL_PORT'), 'connectionString' => 'tls://'.env('PAPERTRAIL_URL').':'.env('PAPERTRAIL_PORT'), ], 'processors' => [PsrLogMessageProcessor::class], ], 'stderr' => [ - 'driver' => 'monolog', - 'level' => env('LOG_LEVEL', 'debug'), - 'handler' => StreamHandler::class, + 'driver' => 'monolog', + 'level' => env('LOG_LEVEL', 'debug'), + 'handler' => StreamHandler::class, 'formatter' => env('LOG_STDERR_FORMATTER'), - 'with' => [ + 'with' => [ 'stream' => 'php://stderr', ], 'processors' => [PsrLogMessageProcessor::class], ], 'syslog' => [ - 'driver' => 'syslog', - 'level' => env('LOG_LEVEL', 'debug'), - 'facility' => LOG_USER, + 'driver' => 'syslog', + 'level' => env('LOG_LEVEL', 'debug'), + 'facility' => LOG_USER, 'replace_placeholders' => true, ], 'errorlog' => [ - 'driver' => 'errorlog', - 'level' => env('LOG_LEVEL', 'debug'), + 'driver' => 'errorlog', + 'level' => env('LOG_LEVEL', 'debug'), 'replace_placeholders' => true, ], 'null' => [ - 'driver' => 'monolog', + 'driver' => 'monolog', 'handler' => NullHandler::class, ], diff --git a/config/mail.php b/config/mail.php index e894b2e..a17d4ea 100644 --- a/config/mail.php +++ b/config/mail.php @@ -35,14 +35,14 @@ 'mailers' => [ 'smtp' => [ - 'transport' => 'smtp', - 'url' => env('MAIL_URL'), - 'host' => env('MAIL_HOST', 'smtp.mailgun.org'), - 'port' => env('MAIL_PORT', 587), - 'encryption' => env('MAIL_ENCRYPTION', 'tls'), - 'username' => env('MAIL_USERNAME'), - 'password' => env('MAIL_PASSWORD'), - 'timeout' => null, + 'transport' => 'smtp', + 'url' => env('MAIL_URL'), + 'host' => env('MAIL_HOST', 'smtp.mailgun.org'), + 'port' => env('MAIL_PORT', 587), + 'encryption' => env('MAIL_ENCRYPTION', 'tls'), + 'username' => env('MAIL_USERNAME'), + 'password' => env('MAIL_PASSWORD'), + 'timeout' => null, 'local_domain' => env('MAIL_EHLO_DOMAIN'), ], @@ -67,12 +67,12 @@ 'sendmail' => [ 'transport' => 'sendmail', - 'path' => env('MAIL_SENDMAIL_PATH', '/usr/sbin/sendmail -bs -i'), + 'path' => env('MAIL_SENDMAIL_PATH', '/usr/sbin/sendmail -bs -i'), ], 'log' => [ 'transport' => 'log', - 'channel' => env('MAIL_LOG_CHANNEL'), + 'channel' => env('MAIL_LOG_CHANNEL'), ], 'array' => [ @@ -81,7 +81,7 @@ 'failover' => [ 'transport' => 'failover', - 'mailers' => [ + 'mailers' => [ 'smtp', 'log', ], @@ -89,7 +89,7 @@ 'roundrobin' => [ 'transport' => 'roundrobin', - 'mailers' => [ + 'mailers' => [ 'ses', 'postmark', ], @@ -109,7 +109,7 @@ 'from' => [ 'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'), - 'name' => env('MAIL_FROM_NAME', 'Example'), + 'name' => env('MAIL_FROM_NAME', 'Example'), ], /* diff --git a/config/queue.php b/config/queue.php index 01c6b05..1c01f21 100644 --- a/config/queue.php +++ b/config/queue.php @@ -35,39 +35,39 @@ ], 'database' => [ - 'driver' => 'database', - 'table' => 'jobs', - 'queue' => 'default', - 'retry_after' => 90, + 'driver' => 'database', + 'table' => 'jobs', + 'queue' => 'default', + 'retry_after' => 90, 'after_commit' => false, ], 'beanstalkd' => [ - 'driver' => 'beanstalkd', - 'host' => 'localhost', - 'queue' => 'default', - 'retry_after' => 90, - 'block_for' => 0, + 'driver' => 'beanstalkd', + 'host' => 'localhost', + 'queue' => 'default', + 'retry_after' => 90, + 'block_for' => 0, 'after_commit' => false, ], 'sqs' => [ - 'driver' => 'sqs', - 'key' => env('AWS_ACCESS_KEY_ID'), - 'secret' => env('AWS_SECRET_ACCESS_KEY'), - 'prefix' => env('SQS_PREFIX', 'https://sqs.us-east-1.amazonaws.com/your-account-id'), - 'queue' => env('SQS_QUEUE', 'default'), - 'suffix' => env('SQS_SUFFIX'), - 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), + 'driver' => 'sqs', + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'prefix' => env('SQS_PREFIX', 'https://sqs.us-east-1.amazonaws.com/your-account-id'), + 'queue' => env('SQS_QUEUE', 'default'), + 'suffix' => env('SQS_SUFFIX'), + 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), 'after_commit' => false, ], 'redis' => [ - 'driver' => 'redis', - 'connection' => 'default', - 'queue' => env('REDIS_QUEUE', 'default'), - 'retry_after' => 90, - 'block_for' => null, + 'driver' => 'redis', + 'connection' => 'default', + 'queue' => env('REDIS_QUEUE', 'default'), + 'retry_after' => 90, + 'block_for' => null, 'after_commit' => false, ], @@ -86,7 +86,7 @@ 'batching' => [ 'database' => env('DB_CONNECTION', 'mysql'), - 'table' => 'job_batches', + 'table' => 'job_batches', ], /* @@ -101,9 +101,9 @@ */ 'failed' => [ - 'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'), + 'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'), 'database' => env('DB_CONNECTION', 'mysql'), - 'table' => 'failed_jobs', + 'table' => 'failed_jobs', ], ]; diff --git a/config/sanctum.php b/config/sanctum.php index 764a82f..4c7d412 100644 --- a/config/sanctum.php +++ b/config/sanctum.php @@ -76,8 +76,8 @@ 'middleware' => [ 'authenticate_session' => Laravel\Sanctum\Http\Middleware\AuthenticateSession::class, - 'encrypt_cookies' => Illuminate\Cookie\Middleware\EncryptCookies::class, - 'validate_csrf_token' => Illuminate\Foundation\Http\Middleware\ValidateCsrfToken::class, + 'encrypt_cookies' => Illuminate\Cookie\Middleware\EncryptCookies::class, + 'validate_csrf_token' => Illuminate\Foundation\Http\Middleware\ValidateCsrfToken::class, ], ]; diff --git a/config/services.php b/config/services.php index 20ab06a..8693d93 100644 --- a/config/services.php +++ b/config/services.php @@ -15,10 +15,10 @@ */ 'mailgun' => [ - 'domain' => env('MAILGUN_DOMAIN'), - 'secret' => env('MAILGUN_SECRET'), + 'domain' => env('MAILGUN_DOMAIN'), + 'secret' => env('MAILGUN_SECRET'), 'endpoint' => env('MAILGUN_ENDPOINT', 'api.mailgun.net'), - 'scheme' => 'https', + 'scheme' => 'https', ], 'postmark' => [ @@ -26,20 +26,26 @@ ], 'ses' => [ - 'key' => env('AWS_ACCESS_KEY_ID'), + 'key' => env('AWS_ACCESS_KEY_ID'), 'secret' => env('AWS_SECRET_ACCESS_KEY'), 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), ], 'rapidapi' => [ - 'key' => env('RAPIDAPI_KEY'), + 'key' => env('RAPIDAPI_KEY'), 'host' => env('RAPIDAPI_HOST', 'amazon23.p.rapidapi.com'), ], 'openai' => [ 'api_key' => env('OPENAI_API_KEY'), - 'model' => env('OPENAI_MODEL', 'gpt-4'), + 'model' => env('OPENAI_MODEL', 'gpt-4o-mini'), 'base_url' => env('OPENAI_BASE_URL', 'https://api.openai.com/v1'), + 'timeout' => env('OPENAI_TIMEOUT', 120), + 'parallel_threshold' => env('OPENAI_PARALLEL_THRESHOLD', 50), + 'chunk_size' => env('OPENAI_CHUNK_SIZE', 25), ], + // Amazon validation is now handled client-side only + // Server skips validation to avoid IP throttling + ]; diff --git a/cookies.txt b/cookies.txt new file mode 100644 index 0000000..e2d04b9 --- /dev/null +++ b/cookies.txt @@ -0,0 +1,6 @@ +# Netscape HTTP Cookie File +# https://curl.se/docs/http-cookies.html +# This file was generated by libcurl! Edit at your own risk. + +#HttpOnly_faker.local FALSE / FALSE 1749685405 faker_session eyJpdiI6InhHMHF2ZDlvM2paUzVaeXlPd3lPR3c9PSIsInZhbHVlIjoiVGluelVhWFpwSjhnc0Q4V0pkQ21RdUVab2Fka0pxcU9pQlROZ21oMENaMWdydTJQSWlMaWwvVmliUGRqMisyTGNuNDhtU2dOcmJPdWhldVhOa0tTbnpBbWE0NFJCVlNERnptWElQaTZTUnJEMUNJTkJjOW85eTV2NTVOdUErQm8iLCJtYWMiOiJiOWM2ZTc0OGRkNjllYjcwNGU0Y2YwZmNmMzU0NzgxOTg2YTgyNGQ1NmY3MmNiYWYxNTdiMmI2NjA5NjBmYmYwIiwidGFnIjoiIn0%3D +faker.local FALSE / FALSE 1749685405 XSRF-TOKEN eyJpdiI6IlJUczRSL0creFNLU29IRlU0a2xuSkE9PSIsInZhbHVlIjoiOVUwWHpFUjFZb1RMZEJHamtSem1OZ2RadDR6YittZnhNRm93ME9VU0NqK3p1ZlRnT0E1RFRJWWlCelRvZXI2cjJRdHVDeWRMWWhvYTFzenRXaU1rT0pBbThsTzllNmlmak9NUG9hK2FSdjlqZndGTGw2eXlLOEZyYkhmdmYxdjMiLCJtYWMiOiI0ZjUwYjEwM2MyNzlhMDRmZTE2N2QxMzA1OTlkZWViOTQwMWNkN2JjYjhkMTBjN2QxZGIwNTdjZjc5OTJmZDM1IiwidGFnIjoiIn0%3D diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index 584104c..e0c9536 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -24,11 +24,11 @@ class UserFactory extends Factory public function definition(): array { return [ - 'name' => fake()->name(), - 'email' => fake()->unique()->safeEmail(), + 'name' => fake()->name(), + 'email' => fake()->unique()->safeEmail(), 'email_verified_at' => now(), - 'password' => static::$password ??= Hash::make('password'), - 'remember_token' => Str::random(10), + 'password' => static::$password ??= Hash::make('password'), + 'remember_token' => Str::random(10), ]; } diff --git a/database/migrations/2014_10_12_000000_create_users_table.php b/database/migrations/2014_10_12_000000_create_users_table.php index 444fafb..2fb647b 100644 --- a/database/migrations/2014_10_12_000000_create_users_table.php +++ b/database/migrations/2014_10_12_000000_create_users_table.php @@ -4,8 +4,7 @@ use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; -return new class extends Migration -{ +return new class() extends Migration { /** * Run the migrations. */ diff --git a/database/migrations/2014_10_12_100000_create_password_reset_tokens_table.php b/database/migrations/2014_10_12_100000_create_password_reset_tokens_table.php index 81a7229..e354188 100644 --- a/database/migrations/2014_10_12_100000_create_password_reset_tokens_table.php +++ b/database/migrations/2014_10_12_100000_create_password_reset_tokens_table.php @@ -4,8 +4,7 @@ use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; -return new class extends Migration -{ +return new class() extends Migration { /** * Run the migrations. */ diff --git a/database/migrations/2019_08_19_000000_create_failed_jobs_table.php b/database/migrations/2019_08_19_000000_create_failed_jobs_table.php index 249da81..6876828 100644 --- a/database/migrations/2019_08_19_000000_create_failed_jobs_table.php +++ b/database/migrations/2019_08_19_000000_create_failed_jobs_table.php @@ -4,8 +4,7 @@ use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; -return new class extends Migration -{ +return new class() extends Migration { /** * Run the migrations. */ diff --git a/database/migrations/2019_12_14_000001_create_personal_access_tokens_table.php b/database/migrations/2019_12_14_000001_create_personal_access_tokens_table.php index e828ad8..e637aaf 100644 --- a/database/migrations/2019_12_14_000001_create_personal_access_tokens_table.php +++ b/database/migrations/2019_12_14_000001_create_personal_access_tokens_table.php @@ -4,8 +4,7 @@ use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; -return new class extends Migration -{ +return new class() extends Migration { /** * Run the migrations. */ diff --git a/database/migrations/2025_05_27_085749_create_asin_reviews_table.php b/database/migrations/2025_05_27_085749_create_asin_reviews_table.php index e12c11c..dc35594 100644 --- a/database/migrations/2025_05_27_085749_create_asin_reviews_table.php +++ b/database/migrations/2025_05_27_085749_create_asin_reviews_table.php @@ -34,4 +34,4 @@ public function down(): void { Schema::dropIfExists('asin_reviews'); } -} \ No newline at end of file +} diff --git a/database/migrations/2025_05_27_180704_add_openai_result_column.php b/database/migrations/2025_05_27_180704_add_openai_result_column.php index 78958d6..f310b33 100644 --- a/database/migrations/2025_05_27_180704_add_openai_result_column.php +++ b/database/migrations/2025_05_27_180704_add_openai_result_column.php @@ -4,8 +4,7 @@ use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; -return new class extends Migration -{ +return new class() extends Migration { /** * Run the migrations. */ diff --git a/database/migrations/2025_05_29_181643_add_rating_columns_to_asin_reviews_table.php b/database/migrations/2025_05_29_181643_add_rating_columns_to_asin_reviews_table.php index 18a979e..fe86b0e 100644 --- a/database/migrations/2025_05_29_181643_add_rating_columns_to_asin_reviews_table.php +++ b/database/migrations/2025_05_29_181643_add_rating_columns_to_asin_reviews_table.php @@ -20,4 +20,4 @@ public function down() $table->dropColumn(['amazon_rating', 'adjusted_rating']); }); } -} \ No newline at end of file +} diff --git a/database/migrations/2025_05_29_184402_rename_asin_reviews_table_to_asin_data.php b/database/migrations/2025_05_29_184402_rename_asin_reviews_table_to_asin_data.php index e1ffb08..4631d70 100644 --- a/database/migrations/2025_05_29_184402_rename_asin_reviews_table_to_asin_data.php +++ b/database/migrations/2025_05_29_184402_rename_asin_reviews_table_to_asin_data.php @@ -1,11 +1,9 @@ decimal('adjusted_rating', 3, 2)->default(0); }); } -}; \ No newline at end of file +}; diff --git a/database/migrations/2025_05_30_183203_remove_analysis_from_asin_data_table.php b/database/migrations/2025_05_30_183203_remove_analysis_from_asin_data_table.php index d89185b..cb70dca 100644 --- a/database/migrations/2025_05_30_183203_remove_analysis_from_asin_data_table.php +++ b/database/migrations/2025_05_30_183203_remove_analysis_from_asin_data_table.php @@ -4,8 +4,7 @@ use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; -return new class extends Migration -{ +return new class() extends Migration { public function up() { Schema::table('asin_data', function (Blueprint $table) { @@ -19,4 +18,4 @@ public function down() $table->json('analysis')->nullable(); }); } -}; \ No newline at end of file +}; diff --git a/database/migrations/2025_06_09_190029_create_sessions_table.php b/database/migrations/2025_06_09_190029_create_sessions_table.php index f60625b..01e84f5 100644 --- a/database/migrations/2025_06_09_190029_create_sessions_table.php +++ b/database/migrations/2025_06_09_190029_create_sessions_table.php @@ -4,8 +4,7 @@ use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; -return new class extends Migration -{ +return new class() extends Migration { /** * Run the migrations. */ diff --git a/resources/views/home.blade.php b/resources/views/home.blade.php index 6a05109..3721f2e 100644 --- a/resources/views/home.blade.php +++ b/resources/views/home.blade.php @@ -3,6 +3,7 @@ + Null Fake - Amazon Review Analysis @@ -91,7 +92,7 @@ function gtag(){dataLayer.push(arguments);}
-
+
Null Fake Logo

Analyze Amazon reviews for authenticity

@@ -99,20 +100,36 @@ function gtag(){dataLayer.push(arguments);}
-
AI-powered review analyzer. Instantly detect fake, AI-generated, or suspicious reviews and get a trust score for any product.
+
AI-powered review analyzer. Easily detect fake, AI-generated or suspicious reviews and get a trust score for any product. Currently only US products are supported.
{{-- Livewire Review Analyzer replaces the form --}}
@livewireScripts diff --git a/resources/views/livewire/review-analyzer.blade.php b/resources/views/livewire/review-analyzer.blade.php index 8cf57c9..7010a9c 100644 --- a/resources/views/livewire/review-analyzer.blade.php +++ b/resources/views/livewire/review-analyzer.blade.php @@ -4,48 +4,52 @@
- + @error('productUrl')
{{ $message }}
@enderror +
- @if(!app()->environment('local')) + @if(!app()->environment(['local', 'testing']))
@if($captcha->getProvider() === 'recaptcha') -
- - @error('g_recaptcha_response') -
{{ $message }}
- @enderror - - + + + @endif @elseif($captcha->getProvider() === 'hcaptcha') @if(!$captcha_passed)
function onHcaptchaSuccess(token) { - @this.set('h_captcha_response', token); + window.Livewire.find('{{ $this->getId() }}').set('h_captcha_response', token); } function renderHcaptcha() { if (typeof hcaptcha !== 'undefined') { @@ -95,7 +99,8 @@ function renderHcaptcha() {