Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 48 additions & 3 deletions apps/importer/src/shopee/ai-review-analyzer.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import crypto from 'node:crypto';
import type { SupabaseClient } from '@supabase/supabase-js';
import type { SupportedAiProvider, AiSpecEnricherConfig } from './ai-spec-enricher.js';
import type { ScrapedReviewer } from './types.js';

/**
* 5-Category Classification for Reviews across Products & Services
Expand Down Expand Up @@ -39,6 +40,8 @@ export interface ReviewInputItem {
downvotes?: number;
report_count?: number;
platform_created_at?: string | null;
/** Reviewer metadata from the scrape, when the platform exposes it. */
reviewer?: ScrapedReviewer | null;
}

export interface AnalyzedReviewResult {
Expand Down Expand Up @@ -179,6 +182,39 @@ export function computeReviewWeight(params: {
return Number(Math.max(0, Math.min(3.0, weight)).toFixed(2));
}

const DAY_MS = 24 * 60 * 60 * 1000;

/** Age of the reviewer's account when the review was written, in days. */
function accountAgeDays(r: ReviewInputItem): number | null {
const joined = Date.parse(r.reviewer?.joinedAt || '');
if (!Number.isFinite(joined)) return null;
// Age at the time of writing; without a review date we would be measuring today's age.
const written = Date.parse(r.platform_created_at || '');
if (!Number.isFinite(written)) return null;
const days = (written - joined) / DAY_MS;
return days >= 0 ? days : null;
}

/** `unknown` when the platform does not expose the join date. */
export function accountAgeBucket(r: ReviewInputItem): ReviewSignals['account_age_bucket'] {
const days = accountAgeDays(r);
if (days === null) return 'unknown';
if (days < 30) return 'low';
if (days < 365) return 'medium';
return 'high';
}

/**
* 0..1, higher = more established reviewer. Log-scaled on the account's total review
* count (0 -> 0, 9 -> 0.5, 99+ -> 1). Falls back to the old neutral prior when unknown.
*/
export function reviewerHistoryScore(r: ReviewInputItem): number {
if (r.reviewer_type === 'expert') return 1.0;
const total = r.reviewer?.totalReviews;
if (typeof total !== 'number' || !Number.isFinite(total) || total < 0) return 0.7;
return clamp01(Math.log10(1 + total) / 2);
}

function normalizeTextForDedupe(text: string): string {
return text
.toLowerCase()
Expand Down Expand Up @@ -369,8 +405,8 @@ export class AiReviewAnalyzer {
signals: {
burst_pattern_score: clamp01(burstScore),
duplicate_text_ratio: clamp01(duplicateRatio),
account_age_bucket: r.is_verified_buyer === false ? 'low' : 'medium',
reviewer_history_score: r.reviewer_type === 'expert' ? 1.0 : 0.7,
account_age_bucket: accountAgeBucket(r),
reviewer_history_score: reviewerHistoryScore(r),
language_marker_score: clamp01(languageMarker),
},
};
Expand Down Expand Up @@ -446,6 +482,8 @@ Classify each review into one of 5 exact flag_category values:
4. "competitor_sabotage": Malicious 1-2 star attack promoting a rival shop or baseless defamation. Ignored in rating, protects seller.
5. "review_bombing": Review left after cancelling an order without using the product/service, or mob brigading. Ignored in rating.

Reviewer fields (verified_purchase, reviewer_anonymous, reviewer_account_age_days, reviewer_total_reviews, repeat_purchase) are null when the platform does not expose them; null is NOT evidence of fraud. A brand-new account with no other reviews posting generic praise is supporting evidence, never proof on its own.

Return ONLY valid JSON matching:
{
"reviews": [
Expand Down Expand Up @@ -480,7 +518,14 @@ Return ONLY valid JSON matching:
author: r.author_name,
rating_star: r.rating_star,
comment: r.comment,
is_verified_buyer: r.is_verified_buyer ?? true,
verified_purchase: r.reviewer?.verifiedPurchase ?? r.is_verified_buyer ?? null,
reviewer_anonymous: r.reviewer?.isAnonymous ?? null,
reviewer_account_age_days: (() => {
const days = accountAgeDays(r);
return days === null ? null : Math.round(days);
})(),
reviewer_total_reviews: r.reviewer?.totalReviews ?? null,
repeat_purchase: r.reviewer?.repeatPurchase ?? null,
heuristic_hint: batchBaseline[idx]?.flag_category,
})),
});
Expand Down
62 changes: 51 additions & 11 deletions apps/importer/src/shopee/shopee-to-supabase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,29 @@
import crypto from 'node:crypto';
import dotenv from 'dotenv';
import { createClient, SupabaseClient } from '@supabase/supabase-js';
import type { ShopeeProduct, SupportedEcommercePlatform } from './types.js';
import type { ShopeeProduct, ShopeeReview, SupportedEcommercePlatform } from './types.js';
import { ProductNormalizer } from './product-normalizer.js';
import { EntityMatcher } from './entity-matcher.js';
import { AiSpecEnricher, type AiSpecEnricherConfig } from './ai-spec-enricher.js';
import { AiReviewAnalyzer } from './ai-review-analyzer.js';
import { AiReviewAnalyzer, type ReviewInputItem } from './ai-review-analyzer.js';

dotenv.config();

function hashEntity(val: string): string {
return '0x' + crypto.createHash('sha256').update(val.toLowerCase()).digest('hex');
}

function platformReviewIdFor(r: ShopeeReview): string {
return (
r.reviewId ||
'rv-' +
crypto
.createHash('sha256')
.update(`${r.author || ''}|${r.comment || ''}`)
.digest('hex')
);
}

export class ShopeeIngestionPipeline {
private supabase: SupabaseClient<any, 'rate'>;
private normalizer: ProductNormalizer;
Expand Down Expand Up @@ -267,19 +278,15 @@ export class ShopeeIngestionPipeline {
if (product.reviews && product.reviews.length > 0) {
const reviewRows = product.reviews.map(r => ({
product_id: productId,
platform_review_id:
r.reviewId ||
'rv-' +
crypto
.createHash('sha256')
.update(`${r.author || ''}|${r.comment || ''}`)
.digest('hex'),
platform_review_id: platformReviewIdFor(r),
author_name: r.author,
author_avatar: r.authorAvatar || null,
rating_star: r.ratingStar,
comment: r.comment,
variation: r.variation || null,
images: r.images || [],
// null = the platform does not say (the column default `true` would claim a purchase we cannot confirm)
is_verified_buyer: r.reviewer?.verifiedPurchase ?? null,
Comment thread
JOY marked this conversation as resolved.
platform_created_at: r.createdAt,
}));

Expand Down Expand Up @@ -343,6 +350,24 @@ export class ShopeeIngestionPipeline {
);
}
}
// Rows stored before the platform's purchase flag was captured still carry the
// column default; refresh it for reviews already in the DB.
for (const verified of [true, false]) {
const ids = product.reviews
.filter(r => r.reviewer?.verifiedPurchase === verified)
.map(platformReviewIdFor);
if (ids.length === 0) continue;
const { error: verifiedErr } = await this.supabase
.from('reviews')
.update({ is_verified_buyer: verified })
.eq('product_id', productId)
.in('platform_review_id', ids);
if (verifiedErr) {
console.warn(
`[MultiPlatformIngest:${platform}] Could not refresh is_verified_buyer: ${verifiedErr.message}`
);
}
}
await report?.('reviews');
}

Expand All @@ -351,12 +376,27 @@ export class ShopeeIngestionPipeline {
const { data: storedReviews, error: storedErr } = await this.supabase
.from('reviews')
.select(
'id, author_name, rating_star, comment, variation, images, is_verified_buyer, reviewer_type, upvotes, downvotes, report_count, platform_created_at'
'id, platform_review_id, author_name, rating_star, comment, variation, images, is_verified_buyer, reviewer_type, upvotes, downvotes, report_count, platform_created_at'
)
.eq('product_id', productId)
.order('created_at', { ascending: false })
.limit(200);

// Reviewer metadata is not persisted yet; attach what this scrape saw so the analyzer can use it.
const reviewerByPlatformId = new Map(
(product.reviews || [])
.filter(r => r.reviewer)
.map(r => [platformReviewIdFor(r), r.reviewer] as const)
);
const analysisInput: ReviewInputItem[] = (storedReviews || []).map(
(row: ReviewInputItem & { platform_review_id?: string | null }) => ({
...row,
reviewer: row.platform_review_id
? reviewerByPlatformId.get(row.platform_review_id)
: undefined,
})
);

if (storedErr) {
console.warn(
`[MultiPlatformIngest:${platform}] Review analysis skipped (fetch failed): ${storedErr.message}`
Expand All @@ -374,7 +414,7 @@ export class ShopeeIngestionPipeline {
productId,
masterId,
merchantId,
reviews: storedReviews,
reviews: analysisInput,
entityName: candidate.name,
entityType: 'product',
platform,
Expand Down
Loading
Loading