feat: adjust product review analysis form & minor styling
This commit is contained in:
parent
9029319096
commit
ee1d2acf01
|
|
@ -4,6 +4,13 @@ const nextConfig: NextConfig = {
|
||||||
images: {
|
images: {
|
||||||
domains: ["lh3.googleusercontent.com"],
|
domains: ["lh3.googleusercontent.com"],
|
||||||
},
|
},
|
||||||
|
experimental: {
|
||||||
|
serverComponentsExternalPackages: [
|
||||||
|
"puppeteer",
|
||||||
|
"puppeteer-extra",
|
||||||
|
"puppeteer-extra-plugin-stealth",
|
||||||
|
],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -25,6 +25,9 @@
|
||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
"next-auth": "^4.24.13",
|
"next-auth": "^4.24.13",
|
||||||
"pg": "^8.18.0",
|
"pg": "^8.18.0",
|
||||||
|
"puppeteer": "^24.37.2",
|
||||||
|
"puppeteer-extra": "^3.3.6",
|
||||||
|
"puppeteer-extra-plugin-stealth": "^2.11.2",
|
||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3",
|
"react-dom": "19.2.3",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,339 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button } from "@/src/components/ui/button";
|
||||||
|
import { Input } from "@/src/components/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/src/components/ui/select";
|
||||||
|
import { useAnalyseText } from "@/src/hooks/useAnalyzeText";
|
||||||
|
import { CheckCircle2, Sparkles, Star, Trophy } from "lucide-react";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
|
||||||
|
export default function AnalysisPage() {
|
||||||
|
const {
|
||||||
|
url1,
|
||||||
|
url2,
|
||||||
|
profession,
|
||||||
|
loading,
|
||||||
|
result,
|
||||||
|
disabled,
|
||||||
|
handleAnalyze,
|
||||||
|
setProfession,
|
||||||
|
setUrl1,
|
||||||
|
setUrl2,
|
||||||
|
setDisabled,
|
||||||
|
} = useAnalyseText();
|
||||||
|
|
||||||
|
const getSentimentTone = (score: number) => {
|
||||||
|
if (score >= 80) return "strong";
|
||||||
|
if (score >= 60) return "light";
|
||||||
|
return "neutral";
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full mx-auto">
|
||||||
|
<div className="bg-white p-6 rounded-lg border">
|
||||||
|
<div className="mb-4 flex items-center gap-2">
|
||||||
|
<Sparkles className="h-5 w-5 text-primary" />
|
||||||
|
<h3 className="text-lg font-semibold">Analisis Sentimen Real-time</h3>
|
||||||
|
</div>
|
||||||
|
<div className="flex w-full gap-4">
|
||||||
|
<div className="w-1/2">
|
||||||
|
<label className="block mb-1 text-sm font-medium text-gray-700">
|
||||||
|
Pilih Profesi/Kebutuhan:
|
||||||
|
</label>
|
||||||
|
<Select value={profession} onValueChange={setProfession} required>
|
||||||
|
<SelectTrigger
|
||||||
|
className={`w-full mb-6 ${!profession ? "text-gray-500" : "text-black"}`}
|
||||||
|
>
|
||||||
|
<SelectValue placeholder="-- Pilih Profesi/Kebutuhan --" />
|
||||||
|
</SelectTrigger>
|
||||||
|
|
||||||
|
<SelectContent
|
||||||
|
className="bg-card border-border shadow-lg"
|
||||||
|
position="popper"
|
||||||
|
>
|
||||||
|
<SelectItem className="cursor-pointer" value="programmer">
|
||||||
|
Programmer
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem className="cursor-pointer" value="designer">
|
||||||
|
Designer
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem className="cursor-pointer" value="student">
|
||||||
|
Student
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem className="cursor-pointer" value="gamer">
|
||||||
|
Gamer
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="w-1/2">
|
||||||
|
<label className="block mb-1 text-sm font-medium text-gray-700">
|
||||||
|
Link Produk Utama
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="Contoh: https://www.tokopedia.com/lenovo/thinkpad-x1-carbon"
|
||||||
|
value={url1}
|
||||||
|
onChange={(e) => setUrl1(e.target.value)}
|
||||||
|
className="border p-2 rounded-md focus:ring-2 focus:ring-green-500"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex w-full gap-4 items-end">
|
||||||
|
<div className="w-1/2">
|
||||||
|
<label className="block mb-1 text-sm font-medium text-gray-700">
|
||||||
|
Link Produk Pembanding
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="Contoh: https://www.tokopedia.com/lenovo/thinkpad-x1-carbon"
|
||||||
|
value={url2}
|
||||||
|
onChange={(e) => setUrl2(e.target.value)}
|
||||||
|
className="border p-2 rounded-md focus:ring-2 focus:ring-green-500"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-1/2 h-max">
|
||||||
|
<Button className="w-full bg-[#F2F8FF] text-primary hover:text-white">
|
||||||
|
+ Tambah Tautan Produk Lainnya
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={handleAnalyze}
|
||||||
|
disabled={!url1 || !url2 || !profession || loading}
|
||||||
|
className={`bg-primary cursor-pointer text-white px-6 py-3 mt-6 rounded-md w-max transition-colors disabled:bg-gray-400`}
|
||||||
|
>
|
||||||
|
<Sparkles className="h-4 w-4 text-white" />
|
||||||
|
{loading
|
||||||
|
? "Sedang Mengambil Ulasan & Menganalisis..."
|
||||||
|
: "Bandingkan Sekarang"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{result && (
|
||||||
|
<motion.div
|
||||||
|
className="mt-12 mx-auto"
|
||||||
|
initial="hidden"
|
||||||
|
animate="visible"
|
||||||
|
variants={{
|
||||||
|
hidden: { opacity: 0 },
|
||||||
|
visible: {
|
||||||
|
opacity: 1,
|
||||||
|
transition: { staggerChildren: 0.15 },
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* ================= HEADER WINNER ================= */}
|
||||||
|
<motion.div
|
||||||
|
variants={{
|
||||||
|
hidden: { opacity: 0, y: -20, height: 0 },
|
||||||
|
visible: {
|
||||||
|
opacity: 1,
|
||||||
|
y: 0,
|
||||||
|
height: "auto",
|
||||||
|
transition: { duration: 0.6, ease: "easeOut" },
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
className="bg-gradient-to-r from-emerald-900 via-green-800 to-emerald-900 text-white p-8 rounded-2xl text-center mb-12 relative overflow-hidden shadow-2xl shadow-emerald-900/30"
|
||||||
|
>
|
||||||
|
<div className="absolute top-0 right-0 -mt-6 -mr-6 w-32 h-32 bg-emerald-400/20 rounded-full blur-3xl"></div>
|
||||||
|
|
||||||
|
<div className="relative z-10">
|
||||||
|
<div className="inline-flex items-center gap-2 bg-white/10 backdrop-blur px-4 py-1.5 rounded-full border border-white/20 mb-4">
|
||||||
|
<Trophy className="w-4 h-4 text-emerald-300" />
|
||||||
|
<span className="text-sm font-semibold tracking-wide uppercase">
|
||||||
|
Rekomendasi Terbaik
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 className="text-3xl md:text-4xl font-extrabold mb-2 tracking-tight">
|
||||||
|
{result.winning_product}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p className="text-emerald-200 text-lg">
|
||||||
|
Pilihan paling tepat untuk{" "}
|
||||||
|
<span className="font-bold capitalize border-b border-emerald-400">
|
||||||
|
{result.profession_target}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* ================= CARDS GRID ================= */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||||
|
{result.details.map((item, index) => {
|
||||||
|
const isWinner = item.name === result.winning_product;
|
||||||
|
|
||||||
|
const getSentimentTone = (score: number) => {
|
||||||
|
if (score >= 80) return "strong";
|
||||||
|
if (score >= 60) return "light";
|
||||||
|
return "neutral";
|
||||||
|
};
|
||||||
|
|
||||||
|
const sentimentTone = getSentimentTone(
|
||||||
|
item.general_sentiment_score,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
key={index}
|
||||||
|
variants={{
|
||||||
|
hidden: { opacity: 0, y: 30 },
|
||||||
|
visible: {
|
||||||
|
opacity: 1,
|
||||||
|
y: 0,
|
||||||
|
transition: { type: "spring", stiffness: 50 },
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
className={`relative group rounded-2xl border transition-all duration-500 backdrop-blur-sm hover:scale-[1.02] ${
|
||||||
|
isWinner
|
||||||
|
? "bg-gradient-to-br from-emerald-900 to-green-800 border-emerald-700 shadow-2xl shadow-emerald-900/30 text-white"
|
||||||
|
: sentimentTone === "strong"
|
||||||
|
? "bg-emerald-800 border-emerald-700 shadow-lg shadow-emerald-900/20 text-white"
|
||||||
|
: sentimentTone === "light"
|
||||||
|
? "bg-emerald-50 border-emerald-200 hover:shadow-lg"
|
||||||
|
: "bg-white border-gray-200 hover:border-gray-300 hover:shadow-lg"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* WINNER BADGE */}
|
||||||
|
{isWinner && (
|
||||||
|
<div className="absolute -top-4 left-1/2 transform -translate-x-1/2 bg-gradient-to-r from-emerald-400 to-green-300 text-emerald-900 px-4 py-1 rounded-full shadow-xl flex items-center gap-1.5 z-20 font-bold">
|
||||||
|
<Trophy className="w-3.5 h-3.5" />
|
||||||
|
<span className="text-xs uppercase tracking-wider">
|
||||||
|
Top Choice
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="p-6 md:p-8 h-full flex flex-col">
|
||||||
|
{/* PRODUCT NAME */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<h3
|
||||||
|
className={`font-bold text-xl leading-snug line-clamp-2 ${
|
||||||
|
sentimentTone === "strong" || isWinner
|
||||||
|
? "text-white"
|
||||||
|
: "text-gray-900"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href={item.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className={`text-xs mt-2 inline-block transition-colors ${
|
||||||
|
sentimentTone === "strong" || isWinner
|
||||||
|
? "text-emerald-200 hover:text-white"
|
||||||
|
: "text-gray-400 hover:text-green-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Lihat di Tokopedia ↗
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* COMPATIBILITY SCORE */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<div className="flex justify-between items-end mb-2">
|
||||||
|
<span className="text-sm font-medium opacity-70">
|
||||||
|
Kecocokan Profesi
|
||||||
|
</span>
|
||||||
|
<span className="text-lg font-bold">
|
||||||
|
{item.profession_compatibility_score}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full bg-black/10 rounded-full h-3 overflow-hidden">
|
||||||
|
<motion.div
|
||||||
|
initial={{ width: 0 }}
|
||||||
|
animate={{
|
||||||
|
width: `${item.profession_compatibility_score}%`,
|
||||||
|
}}
|
||||||
|
transition={{ duration: 1, ease: "easeOut" }}
|
||||||
|
className={`h-full rounded-full ${
|
||||||
|
isWinner
|
||||||
|
? "bg-gradient-to-r from-emerald-400 to-green-300"
|
||||||
|
: "bg-gradient-to-r from-emerald-500 to-emerald-400"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* SENTIMENT SCORE */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<div className="flex justify-between items-end mb-2">
|
||||||
|
<span className="text-sm font-medium opacity-70">
|
||||||
|
Sentimen Publik
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-semibold">
|
||||||
|
{item.general_sentiment_score}% Positif
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`w-full rounded-full h-2 overflow-hidden ${
|
||||||
|
sentimentTone === "strong"
|
||||||
|
? "bg-emerald-900/40"
|
||||||
|
: sentimentTone === "light"
|
||||||
|
? "bg-emerald-100"
|
||||||
|
: "bg-gray-100"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={{ width: 0 }}
|
||||||
|
animate={{
|
||||||
|
width: `${item.general_sentiment_score}%`,
|
||||||
|
}}
|
||||||
|
transition={{ duration: 1, delay: 0.3 }}
|
||||||
|
className={`h-full rounded-full ${
|
||||||
|
sentimentTone === "strong"
|
||||||
|
? "bg-gradient-to-r from-emerald-400 to-emerald-300"
|
||||||
|
: sentimentTone === "light"
|
||||||
|
? "bg-emerald-400"
|
||||||
|
: "bg-gray-300"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* KEYWORDS */}
|
||||||
|
<div className="mt-auto pt-6 border-t border-black/10">
|
||||||
|
<p className="text-xs font-semibold opacity-60 uppercase tracking-wider mb-3">
|
||||||
|
Kata Kunci Dominan
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{item.top_keywords.map((kw, i) => (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
className={`text-xs px-3 py-1 rounded-full font-medium transition-all ${
|
||||||
|
sentimentTone === "strong"
|
||||||
|
? "bg-white/10 text-emerald-200 border border-white/20"
|
||||||
|
: sentimentTone === "light"
|
||||||
|
? "bg-emerald-100 text-emerald-700 border border-emerald-200"
|
||||||
|
: "bg-gray-50 text-gray-600 border border-gray-100"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
#{kw}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
import { scrapeTokopediaProduct } from "@/src/services/scrape.service";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const { url } = body;
|
||||||
|
|
||||||
|
if (!url || !url.includes("tokopedia.com")) {
|
||||||
|
return NextResponse.json({ error: "URL tidak valid" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await scrapeTokopediaProduct(url);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
import { CLabelProps } from "@/src/types";
|
import { CLabelProps } from "@/src/types";
|
||||||
|
|
||||||
const renderCustomLabel = ({
|
const renderCustomLabel = ({
|
||||||
cx,
|
cx = 0,
|
||||||
cy,
|
cy = 0,
|
||||||
midAngle,
|
midAngle = 0,
|
||||||
innerRadius,
|
innerRadius = 0,
|
||||||
outerRadius,
|
outerRadius = 0,
|
||||||
percent,
|
percent = 0,
|
||||||
}: CLabelProps) => {
|
}: CLabelProps) => {
|
||||||
if (percent < 0.05) return null;
|
if (percent < 0.05) return null;
|
||||||
const RADIAN = Math.PI / 180;
|
const RADIAN = Math.PI / 180;
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,8 @@ import { SentimentChart, TrendChart } from "@/src/utils/dImports";
|
||||||
import { useDashboards } from "@/src/hooks/useDashboard";
|
import { useDashboards } from "@/src/hooks/useDashboard";
|
||||||
import SentimentForm from "./SentimentAnalyzer";
|
import SentimentForm from "./SentimentAnalyzer";
|
||||||
import { WordCloud } from "./WordCloud";
|
import { WordCloud } from "./WordCloud";
|
||||||
|
import AnalysisPage from "@/src/app/analyze/page";
|
||||||
|
import SentimentAnalyzer from "./SentimentAnalyzer";
|
||||||
|
|
||||||
export default function DashboardClient() {
|
export default function DashboardClient() {
|
||||||
const {
|
const {
|
||||||
|
|
@ -130,7 +132,8 @@ export default function DashboardClient() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<SentimentForm />
|
{/* <SentimentAnalyzer /> */}
|
||||||
|
<AnalysisPage />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ export function ModelInfo({ data }: { data: ModelDB[] }) {
|
||||||
<SelectItem
|
<SelectItem
|
||||||
key={model.modelName + index}
|
key={model.modelName + index}
|
||||||
value={index.toString()}
|
value={index.toString()}
|
||||||
|
className="cursor-pointer"
|
||||||
>
|
>
|
||||||
{model.modelName}
|
{model.modelName}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,72 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import { AnalysisResults } from "../types";
|
||||||
|
|
||||||
|
export const useAnalyseText = () => {
|
||||||
|
const [url1, setUrl1] = useState("");
|
||||||
|
const [url2, setUrl2] = useState("");
|
||||||
|
const [profession, setProfession] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [result, setResult] = useState<AnalysisResults | null>(null);
|
||||||
|
const [disabled, setDisabled] = useState(false);
|
||||||
|
|
||||||
|
const handleAnalyze = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setResult(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const scrapePromises = [url1, url2].map((u) =>
|
||||||
|
fetch("/api/scrape", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ url: u }),
|
||||||
|
}).then((res) => res.json()),
|
||||||
|
);
|
||||||
|
|
||||||
|
const scrapeResults = await Promise.all(scrapePromises);
|
||||||
|
|
||||||
|
for (const res of scrapeResults) {
|
||||||
|
if (!res.success)
|
||||||
|
throw new Error("Gagal mengambil data dari salah satu tautan.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidates = scrapeResults.map((res) => ({
|
||||||
|
name: res.data.name,
|
||||||
|
url: res.data.url,
|
||||||
|
reviews: res.data.reviews,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
profession: profession,
|
||||||
|
candidates: candidates,
|
||||||
|
};
|
||||||
|
|
||||||
|
const aiRes = await fetch("http://localhost:8000/recommend", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!aiRes.ok) throw new Error("Gagal melakukan analisis AI");
|
||||||
|
|
||||||
|
const aiResult = await aiRes.json();
|
||||||
|
setResult(aiResult);
|
||||||
|
} catch (error: any) {
|
||||||
|
alert("Terjadi kesalahan: " + error.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
url1,
|
||||||
|
url2,
|
||||||
|
profession,
|
||||||
|
loading,
|
||||||
|
result,
|
||||||
|
disabled,
|
||||||
|
handleAnalyze,
|
||||||
|
setProfession,
|
||||||
|
setUrl1,
|
||||||
|
setUrl2,
|
||||||
|
setDisabled,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,151 @@
|
||||||
|
import puppeteer from "puppeteer";
|
||||||
|
import { ScrapeResult } from "../types";
|
||||||
|
import { getFallbackData } from "../utils/datas";
|
||||||
|
|
||||||
|
export async function scrapeTokopediaProduct(
|
||||||
|
url: string,
|
||||||
|
): Promise<ScrapeResult> {
|
||||||
|
const targetUrl = normalizeToReviewUrl(url);
|
||||||
|
|
||||||
|
let browser;
|
||||||
|
|
||||||
|
try {
|
||||||
|
browser = await puppeteer.launch({
|
||||||
|
headless: true,
|
||||||
|
args: [
|
||||||
|
"--no-sandbox",
|
||||||
|
"--disable-setuid-sandbox",
|
||||||
|
"--window-size=1366,768",
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const page = await browser.newPage();
|
||||||
|
|
||||||
|
await page.setUserAgent(
|
||||||
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36",
|
||||||
|
);
|
||||||
|
await page.setViewport({ width: 1366, height: 768 });
|
||||||
|
|
||||||
|
console.log(`🚀 Scraping URL: ${url}`);
|
||||||
|
|
||||||
|
await page.goto(targetUrl, {
|
||||||
|
waitUntil: "domcontentloaded",
|
||||||
|
timeout: 60000,
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
let totalHeight = 0;
|
||||||
|
const distance = 150;
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
window.scrollBy(0, distance);
|
||||||
|
totalHeight += distance;
|
||||||
|
if (totalHeight >= 2500) {
|
||||||
|
clearInterval(timer);
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise((r) => setTimeout(r, 3000));
|
||||||
|
|
||||||
|
const scrapedData = await page.evaluate(() => {
|
||||||
|
const titleEl = document.querySelector(
|
||||||
|
'[data-testid="lblPDPDetailProductName"]',
|
||||||
|
);
|
||||||
|
let rawName = titleEl ? titleEl.textContent || "" : document.title;
|
||||||
|
|
||||||
|
const cleanProductName = (name: string) => {
|
||||||
|
let cleaned = name
|
||||||
|
.replace(
|
||||||
|
/^(Review|Jual|Promo|Flash Sale|Hot Item|Baru|PROMO)\s+/i,
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
.replace(/^Laptop\s+/i, "")
|
||||||
|
.replace(/\(.*?\)/g, "")
|
||||||
|
.replace(/\[.*?\]/g, "")
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
const specWallRegex =
|
||||||
|
/\b(i[3579]\b|ryzen|celeron|athlon|atom|intel|amd|n\d{4}|z\d{4}|4gb|8gb|12gb|16gb|24gb|32gb|64gb|128gb|256gb|512gb|1tb|2tb|ssd|hdd|emmc|w10|w11|win10|win11|windows|dos|no os|linux|ubuntu|11"|13"|14"|14\.0|15"|15\.6|16"|fhd|hd|qhd|uhd|4k|oled|ips|tn|hz|resmi|garansi|murah)\b/i;
|
||||||
|
|
||||||
|
const splitName = cleaned.split(specWallRegex);
|
||||||
|
|
||||||
|
let finalName = splitName[0].trim();
|
||||||
|
|
||||||
|
finalName = finalName.replace(/[-|,|\/]+\s*$/, "").trim();
|
||||||
|
|
||||||
|
return finalName;
|
||||||
|
};
|
||||||
|
|
||||||
|
const productName = cleanProductName(rawName);
|
||||||
|
|
||||||
|
const elements = document.querySelectorAll("div, span, p");
|
||||||
|
const uniqueReviews = new Set<string>();
|
||||||
|
|
||||||
|
elements.forEach((el) => {
|
||||||
|
const text = el.textContent || "";
|
||||||
|
|
||||||
|
if (text.length > 40 && text.length < 1000) {
|
||||||
|
if (el.children.length === 0) {
|
||||||
|
const cleanText = text.trim().replace(/\n/g, " ");
|
||||||
|
|
||||||
|
const isJunk =
|
||||||
|
cleanText.includes("Lihat Balasan") ||
|
||||||
|
cleanText.includes("Membantu") ||
|
||||||
|
cleanText.includes("Laporkan") ||
|
||||||
|
cleanText.includes("Tokopedia") ||
|
||||||
|
cleanText.includes("Promo") ||
|
||||||
|
cleanText.includes("Diskusi");
|
||||||
|
|
||||||
|
if (!isJunk) {
|
||||||
|
uniqueReviews.add(cleanText);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
productName,
|
||||||
|
reviews: Array.from(uniqueReviews).slice(0, 15),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
await browser.close();
|
||||||
|
|
||||||
|
if (scrapedData.reviews.length === 0) {
|
||||||
|
console.warn("⚠️ Tidak ada ulasan terdeteksi. Menggunakan Fallback.");
|
||||||
|
return getFallbackData(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: scrapedData.productName,
|
||||||
|
url: url,
|
||||||
|
reviews: scrapedData.reviews,
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
if (browser) await browser.close();
|
||||||
|
return getFallbackData(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeToReviewUrl(rawUrl: string): string {
|
||||||
|
try {
|
||||||
|
const urlObj = new URL(rawUrl);
|
||||||
|
|
||||||
|
let cleanPath = urlObj.pathname;
|
||||||
|
|
||||||
|
if (cleanPath.endsWith("/")) {
|
||||||
|
cleanPath = cleanPath.slice(0, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cleanPath.endsWith("/review")) {
|
||||||
|
cleanPath += "/review";
|
||||||
|
}
|
||||||
|
|
||||||
|
return `https://www.tokopedia.com${cleanPath}`;
|
||||||
|
} catch (e) {
|
||||||
|
return rawUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -160,12 +160,12 @@ export type WordCloudConfig = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface CLabelProps {
|
export interface CLabelProps {
|
||||||
cx: number;
|
cx?: number;
|
||||||
cy: number;
|
cy?: number;
|
||||||
midAngle: number;
|
midAngle?: number;
|
||||||
innerRadius: number;
|
innerRadius?: number;
|
||||||
outerRadius: number;
|
outerRadius?: number;
|
||||||
percent: number;
|
percent?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type LegendPayloadItem = {
|
export type LegendPayloadItem = {
|
||||||
|
|
@ -173,3 +173,24 @@ export type LegendPayloadItem = {
|
||||||
name?: string;
|
name?: string;
|
||||||
value?: string;
|
value?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface ScrapeResult {
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
reviews: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProductDetail {
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
general_sentiment_score: number;
|
||||||
|
profession_compatibility_score: number;
|
||||||
|
verdict: string;
|
||||||
|
top_keywords: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalysisResults {
|
||||||
|
profession_target: string;
|
||||||
|
winning_product: string;
|
||||||
|
details: ProductDetail[];
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -73,3 +73,20 @@ export const setWordCloud = ({ maxValue, minValue }: WordCloudConfig) => {
|
||||||
|
|
||||||
return { getSize, getColor };
|
return { getSize, getColor };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function getFallbackData(url: string): ScrapeResult {
|
||||||
|
return {
|
||||||
|
name: "Produk (Data Sampel)",
|
||||||
|
url: url,
|
||||||
|
reviews: [
|
||||||
|
"Laptop ini performanya sangat kencang untuk coding backend dan docker.",
|
||||||
|
"Layar OLED-nya juara banget, warnanya tajam cocok buat desain di Illustrator.",
|
||||||
|
"Keyboard travel distance-nya pas, enak buat ngetik skripsi berjam-jam.",
|
||||||
|
"Sayang port-nya agak sedikit, butuh dongle tambahan.",
|
||||||
|
"Baterai lumayan awet bisa tahan 6-7 jam pemakaian normal office.",
|
||||||
|
"Buat main game ringan seperti Valorant masih oke, fps stabil.",
|
||||||
|
"Build quality kokoh, terasa premium walau body plastik.",
|
||||||
|
"Pengiriman cepat dan packing kayu sangat aman.",
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue