Compare commits
7 Commits
feat/TikTo
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 93573f0efd | |||
|
|
1a5548d507 | ||
|
|
75fd2f5120 | ||
|
|
1a066e14df | ||
|
|
0a8e161c8d | ||
|
|
9bdbe8e153 | ||
| 51a376400c |
@@ -15,12 +15,8 @@ export async function POST() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Supprimer le token TikTok
|
|
||||||
await prisma.tikTokToken.deleteMany({ where: { userId } });
|
await prisma.tikTokToken.deleteMany({ where: { userId } });
|
||||||
|
|
||||||
// Supprimer le TrackedAccount associé
|
|
||||||
await prisma.trackedAccount.deleteMany({ where: { userId, platform: "tiktok" } });
|
|
||||||
|
|
||||||
return NextResponse.json({ success: true });
|
return NextResponse.json({ success: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[TikTok disconnect error]", err);
|
console.error("[TikTok disconnect error]", err);
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ export async function GET(req: NextRequest) {
|
|||||||
followers: true,
|
followers: true,
|
||||||
likes: true,
|
likes: true,
|
||||||
videoCount: true,
|
videoCount: true,
|
||||||
|
views: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -88,7 +89,7 @@ export async function POST(req: NextRequest) {
|
|||||||
if (!userId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
if (!userId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
const { followers, likes, videoCount, displayName, openId } = body;
|
const { followers, likes, videoCount, views, displayName, openId } = body;
|
||||||
|
|
||||||
if (followers === undefined) {
|
if (followers === undefined) {
|
||||||
return NextResponse.json({ error: "Champ 'followers' requis" }, { status: 400 });
|
return NextResponse.json({ error: "Champ 'followers' requis" }, { status: 400 });
|
||||||
@@ -115,7 +116,7 @@ export async function POST(req: NextRequest) {
|
|||||||
followers: followers ?? 0,
|
followers: followers ?? 0,
|
||||||
likes: likes ?? 0,
|
likes: likes ?? 0,
|
||||||
videoCount: videoCount ?? 0,
|
videoCount: videoCount ?? 0,
|
||||||
views: 0,
|
views: views ?? 0,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ export async function GET() {
|
|||||||
followers: 124,
|
followers: 124,
|
||||||
likes: 856,
|
likes: 856,
|
||||||
videoCount: 1,
|
videoCount: 1,
|
||||||
|
views: 3432,
|
||||||
|
profileViews: 287,
|
||||||
displayName: "CrowMate studio",
|
displayName: "CrowMate studio",
|
||||||
avatarUrl: "",
|
avatarUrl: "",
|
||||||
plan: (user as any)?.plan ?? "free",
|
plan: (user as any)?.plan ?? "free",
|
||||||
@@ -56,8 +58,7 @@ export async function GET() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const stats = await fetchUserStats(accessToken, openId);
|
const stats = await fetchUserStats(accessToken, openId);
|
||||||
|
|
||||||
// Upsert TrackedAccount + snapshot automatique
|
|
||||||
try {
|
try {
|
||||||
let account = await prisma.trackedAccount.findFirst({
|
let account = await prisma.trackedAccount.findFirst({
|
||||||
where: { userId, platform: "tiktok" },
|
where: { userId, platform: "tiktok" },
|
||||||
@@ -78,14 +79,12 @@ export async function GET() {
|
|||||||
followers: stats.followers ?? 0,
|
followers: stats.followers ?? 0,
|
||||||
likes: stats.likes ?? 0,
|
likes: stats.likes ?? 0,
|
||||||
videoCount: stats.videoCount ?? 0,
|
videoCount: stats.videoCount ?? 0,
|
||||||
views: 0,
|
views: stats.views ?? 0,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (snapshotErr) {
|
} catch (snapshotErr) {
|
||||||
console.error("[TikTok snapshot save error]", snapshotErr);
|
console.error("[TikTok snapshot save error]", snapshotErr);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inclure le plan dans la réponse
|
|
||||||
const user = await prisma.user.findUnique({ where: { id: userId } });
|
const user = await prisma.user.findUnique({ where: { id: userId } });
|
||||||
return NextResponse.json({ ...stats, plan: (user as any)?.plan ?? "free" });
|
return NextResponse.json({ ...stats, plan: (user as any)?.plan ?? "free" });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Music2, Link2, Unlink, Loader2, AlertTriangle } from "lucide-react";
|
import { Music2, Link2, Unlink, Loader2, AlertTriangle, Eye, EyeOff } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
|
||||||
@@ -11,16 +11,49 @@ interface TikTokStats {
|
|||||||
followers: number;
|
followers: number;
|
||||||
likes: number;
|
likes: number;
|
||||||
videoCount: number;
|
videoCount: number;
|
||||||
|
views: number;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
avatarUrl: string;
|
avatarUrl: string;
|
||||||
plan: "free" | "pro" | "elite" | "team";
|
plan: "free" | "pro" | "elite" | "team";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CardId = "followers" | "likes" | "videos" | "views" | "ratio";
|
||||||
|
|
||||||
|
const defaultVisibleCards: Record<CardId, boolean> = {
|
||||||
|
followers: true,
|
||||||
|
likes: true,
|
||||||
|
videos: true,
|
||||||
|
views: true,
|
||||||
|
ratio: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const visibleCardsStorageKey = "tiktok.visibleCards";
|
||||||
|
|
||||||
|
function parseVisibleCards(value: string | null): Record<CardId, boolean> {
|
||||||
|
if (!value) return defaultVisibleCards;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(value) as Partial<Record<CardId, unknown>>;
|
||||||
|
return {
|
||||||
|
followers: typeof parsed.followers === "boolean" ? parsed.followers : defaultVisibleCards.followers,
|
||||||
|
likes: typeof parsed.likes === "boolean" ? parsed.likes : defaultVisibleCards.likes,
|
||||||
|
videos: typeof parsed.videos === "boolean" ? parsed.videos : defaultVisibleCards.videos,
|
||||||
|
views: typeof parsed.views === "boolean" ? parsed.views : defaultVisibleCards.views,
|
||||||
|
ratio: typeof parsed.ratio === "boolean" ? parsed.ratio : defaultVisibleCards.ratio,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return defaultVisibleCards;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default function TikTokPage() {
|
export default function TikTokPage() {
|
||||||
const [stats, setStats] = useState<TikTokStats | null>(null);
|
const [stats, setStats] = useState<TikTokStats | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [disconnecting, setDisconnecting] = useState(false);
|
const [disconnecting, setDisconnecting] = useState(false);
|
||||||
|
const [showDisconnectConfirm, setShowDisconnectConfirm] = useState(false);
|
||||||
|
const [visibleCards, setVisibleCards] = useState<Record<CardId, boolean>>(defaultVisibleCards);
|
||||||
|
const [visibleCardsHydrated, setVisibleCardsHydrated] = useState(false);
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
@@ -46,14 +79,24 @@ export default function TikTokPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const persisted = parseVisibleCards(window.localStorage.getItem(visibleCardsStorageKey));
|
||||||
|
setVisibleCards(persisted);
|
||||||
|
setVisibleCardsHydrated(true);
|
||||||
loadStats();
|
loadStats();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visibleCardsHydrated) return;
|
||||||
|
window.localStorage.setItem(visibleCardsStorageKey, JSON.stringify(visibleCards));
|
||||||
|
}, [visibleCards, visibleCardsHydrated]);
|
||||||
|
|
||||||
async function handleDisconnect() {
|
async function handleDisconnect() {
|
||||||
|
if (disconnecting) return;
|
||||||
setDisconnecting(true);
|
setDisconnecting(true);
|
||||||
try {
|
try {
|
||||||
await fetch("/api/tiktok/disconnect", { method: "POST" });
|
await fetch("/api/tiktok/disconnect", { method: "POST" });
|
||||||
setStats(null);
|
setStats(null);
|
||||||
|
setShowDisconnectConfirm(false);
|
||||||
router.replace("/tiktok");
|
router.replace("/tiktok");
|
||||||
} finally {
|
} finally {
|
||||||
setDisconnecting(false);
|
setDisconnecting(false);
|
||||||
@@ -62,6 +105,49 @@ export default function TikTokPage() {
|
|||||||
|
|
||||||
const urlError = searchParams.get("error");
|
const urlError = searchParams.get("error");
|
||||||
|
|
||||||
|
const cards = stats
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
id: "followers" as const,
|
||||||
|
label: "Followers",
|
||||||
|
value: stats.followers.toLocaleString("fr-FR"),
|
||||||
|
sub: "Abonnés totaux",
|
||||||
|
accent: "purple" as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "likes" as const,
|
||||||
|
label: "Likes totaux",
|
||||||
|
value: stats.likes.toLocaleString("fr-FR"),
|
||||||
|
sub: "Cumul likes",
|
||||||
|
accent: "red" as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "videos" as const,
|
||||||
|
label: "Vidéos publiées",
|
||||||
|
value: stats.videoCount.toLocaleString("fr-FR"),
|
||||||
|
sub: "Vidéos au total",
|
||||||
|
accent: "blue" as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "views" as const,
|
||||||
|
label: "Vues vidéos",
|
||||||
|
value: stats.views.toLocaleString("fr-FR"),
|
||||||
|
sub: "Vues totales des vidéos",
|
||||||
|
accent: "green" as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ratio" as const,
|
||||||
|
label: "Ratio likes/vidéo",
|
||||||
|
value: stats.videoCount > 0 ? Math.round(stats.likes / stats.videoCount).toLocaleString("fr-FR") : "—",
|
||||||
|
sub: "Moy. likes par vidéo",
|
||||||
|
accent: "gold" as const,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const shownCards = cards.filter((card) => visibleCards[card.id]);
|
||||||
|
const hiddenCards = cards.filter((card) => !visibleCards[card.id]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -78,7 +164,7 @@ export default function TikTokPage() {
|
|||||||
|
|
||||||
{stats && !loading && (
|
{stats && !loading && (
|
||||||
<button
|
<button
|
||||||
onClick={handleDisconnect}
|
onClick={() => setShowDisconnectConfirm(true)}
|
||||||
disabled={disconnecting}
|
disabled={disconnecting}
|
||||||
className="flex items-center gap-2 px-4 py-2 text-[10px] font-mono tracking-widest uppercase border border-red-500/30 text-red-400/70 hover:text-red-400 hover:border-red-500/60 rounded-sm transition-colors disabled:opacity-50"
|
className="flex items-center gap-2 px-4 py-2 text-[10px] font-mono tracking-widest uppercase border border-red-500/30 text-red-400/70 hover:text-red-400 hover:border-red-500/60 rounded-sm transition-colors disabled:opacity-50"
|
||||||
>
|
>
|
||||||
@@ -120,32 +206,69 @@ export default function TikTokPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-2 xl:grid-cols-4 gap-4 mb-8">
|
{hiddenCards.length > 0 && (
|
||||||
<StatCard
|
<div className="flex flex-wrap items-center gap-2 mb-3">
|
||||||
label="Followers"
|
{hiddenCards.map((card) => (
|
||||||
value={stats.followers.toLocaleString("fr-FR")}
|
<button
|
||||||
sub="Abonnés totaux"
|
key={card.id}
|
||||||
accent="purple"
|
type="button"
|
||||||
/>
|
onClick={() => setVisibleCards((prev) => ({ ...prev, [card.id]: true }))}
|
||||||
<StatCard
|
className="inline-flex items-center gap-1 px-2 py-1 text-[10px] font-mono tracking-widest uppercase border rounded-sm transition-colors hover:text-pink-300"
|
||||||
label="Likes totaux"
|
style={{ borderColor: "var(--border)", color: "var(--text-secondary)" }}
|
||||||
value={stats.likes.toLocaleString("fr-FR")}
|
aria-label={`Afficher la carte ${card.label}`}
|
||||||
sub="Cumul likes"
|
>
|
||||||
accent="red"
|
<Eye size={12} />
|
||||||
/>
|
{card.label}
|
||||||
<StatCard
|
</button>
|
||||||
label="Vidéos publiées"
|
))}
|
||||||
value={stats.videoCount.toLocaleString("fr-FR")}
|
<button
|
||||||
sub="Vidéos au total"
|
type="button"
|
||||||
accent="blue"
|
onClick={() => setVisibleCards(defaultVisibleCards)}
|
||||||
/>
|
className="px-2 py-1 text-[10px] font-mono tracking-widest uppercase border rounded-sm transition-colors hover:text-pink-300"
|
||||||
<StatCard
|
style={{ borderColor: "var(--border)", color: "var(--text-secondary)" }}
|
||||||
label="Ratio likes/vidéo"
|
>
|
||||||
value={stats.videoCount > 0 ? Math.round(stats.likes / stats.videoCount).toLocaleString("fr-FR") : "—"}
|
Tout afficher
|
||||||
sub="Moy. likes par vidéo"
|
</button>
|
||||||
accent="gold"
|
</div>
|
||||||
/>
|
)}
|
||||||
</div>
|
|
||||||
|
{shownCards.length > 0 ? (
|
||||||
|
<div id="tiktok-stat-cards" className="grid grid-cols-2 xl:grid-cols-5 gap-4 mb-8">
|
||||||
|
{shownCards.map((card) => (
|
||||||
|
<div key={card.id} className="relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setVisibleCards((prev) => ({ ...prev, [card.id]: false }))}
|
||||||
|
className="absolute top-2 right-2 z-10 p-1 rounded-sm border transition-colors"
|
||||||
|
style={{ borderColor: "var(--border)", color: "var(--text-secondary)", background: "var(--surface)" }}
|
||||||
|
aria-label={`Masquer la carte ${card.label}`}
|
||||||
|
>
|
||||||
|
<EyeOff size={12} />
|
||||||
|
</button>
|
||||||
|
<StatCard
|
||||||
|
label={card.label}
|
||||||
|
value={card.value}
|
||||||
|
sub={card.sub}
|
||||||
|
accent={card.accent}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="border rounded-sm p-6 mb-8 text-center" style={{ background: "var(--surface)", borderColor: "var(--border)" }}>
|
||||||
|
<p className="font-mono text-xs tracking-widest uppercase mb-3" style={{ color: "var(--text-secondary)" }}>
|
||||||
|
Toutes les cartes sont masquées
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setVisibleCards(defaultVisibleCards)}
|
||||||
|
className="px-3 py-2 text-[10px] font-mono tracking-widest uppercase border rounded-sm transition-colors hover:text-pink-300"
|
||||||
|
style={{ borderColor: "var(--border)", color: "var(--text-secondary)" }}
|
||||||
|
>
|
||||||
|
Afficher toutes les cartes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Graph */}
|
{/* Graph */}
|
||||||
<StatsChart plan={stats.plan ?? "free"} />
|
<StatsChart plan={stats.plan ?? "free"} />
|
||||||
@@ -180,6 +303,40 @@ export default function TikTokPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showDisconnectConfirm && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 px-4">
|
||||||
|
<div className="w-full max-w-md rounded-sm border p-5" style={{ background: "var(--surface)", borderColor: "var(--border)" }}>
|
||||||
|
<div className="flex items-center gap-2 mb-3 text-red-400">
|
||||||
|
<AlertTriangle size={16} />
|
||||||
|
<p className="text-xs font-mono tracking-widest uppercase">Confirmer la déconnexion</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm mb-5" style={{ color: "var(--text-secondary)" }}>
|
||||||
|
Êtes-vous sûr de vouloir déconnecter votre compte TikTok ?
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowDisconnectConfirm(false)}
|
||||||
|
disabled={disconnecting}
|
||||||
|
className="px-3 py-2 text-[10px] font-mono tracking-widest uppercase border rounded-sm transition-colors disabled:opacity-50"
|
||||||
|
style={{ borderColor: "var(--border)", color: "var(--text-secondary)" }}
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleDisconnect}
|
||||||
|
disabled={disconnecting}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 text-[10px] font-mono tracking-widest uppercase border border-red-500/40 text-red-300 hover:text-red-200 hover:border-red-500/70 rounded-sm transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{disconnecting ? <Loader2 size={12} className="animate-spin" /> : <Unlink size={12} />}
|
||||||
|
Confirmer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -13,13 +13,14 @@ import {
|
|||||||
import { TrendingUp, TrendingDown, Minus, Lock } from "lucide-react";
|
import { TrendingUp, TrendingDown, Minus, Lock } from "lucide-react";
|
||||||
|
|
||||||
type Period = "7d" | "30d" | "90d" | "all";
|
type Period = "7d" | "30d" | "90d" | "all";
|
||||||
type Metric = "followers" | "likes" | "videoCount";
|
type Metric = "followers" | "likes" | "videoCount" | "views";
|
||||||
|
|
||||||
interface Snapshot {
|
interface Snapshot {
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
followers: number;
|
followers: number;
|
||||||
likes: number;
|
likes: number;
|
||||||
videoCount: number;
|
videoCount: number;
|
||||||
|
views: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface StatsChartProps {
|
interface StatsChartProps {
|
||||||
@@ -39,6 +40,7 @@ const METRICS: { value: Metric; label: string; color: string; gradientId: string
|
|||||||
{ value: "followers", label: "Followers", color: "#c084fc", gradientId: "gradFollowers" },
|
{ value: "followers", label: "Followers", color: "#c084fc", gradientId: "gradFollowers" },
|
||||||
{ value: "likes", label: "Likes", color: "#f472b6", gradientId: "gradLikes" },
|
{ value: "likes", label: "Likes", color: "#f472b6", gradientId: "gradLikes" },
|
||||||
{ value: "videoCount", label: "Vidéos", color: "#60a5fa", gradientId: "gradVideos" },
|
{ value: "videoCount", label: "Vidéos", color: "#60a5fa", gradientId: "gradVideos" },
|
||||||
|
{ value: "views", label: "Vues", color: "#22c55e", gradientId: "gradViews" },
|
||||||
];
|
];
|
||||||
|
|
||||||
function canAccess(plan: string, required: string) {
|
function canAccess(plan: string, required: string) {
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
app:
|
app:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
command: sh -c "sleep 30 && node worker/snapshot-worker.js"
|
command: sh -c "sleep 30 && tsx worker/snapshot-worker.ts"
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
const TIKTOK_AUTH_URL = "https://www.tiktok.com/v2/auth/authorize";
|
const TIKTOK_AUTH_URL = "https://www.tiktok.com/v2/auth/authorize";
|
||||||
const TIKTOK_TOKEN_URL = "https://open.tiktokapis.com/v2/oauth/token/";
|
const TIKTOK_TOKEN_URL = "https://open.tiktokapis.com/v2/oauth/token/";
|
||||||
const TIKTOK_USER_INFO_URL = "https://open.tiktokapis.com/v2/user/info/";
|
const TIKTOK_USER_INFO_URL = "https://open.tiktokapis.com/v2/user/info/";
|
||||||
|
const TIKTOK_VIDEO_LIST_URL = "https://open.tiktokapis.com/v2/video/list/";
|
||||||
|
|
||||||
const CLIENT_KEY = process.env.TIKTOK_CLIENT_KEY!;
|
const CLIENT_KEY = process.env.TIKTOK_CLIENT_KEY!;
|
||||||
const CLIENT_SECRET = process.env.TIKTOK_CLIENT_SECRET!;
|
const CLIENT_SECRET = process.env.TIKTOK_CLIENT_SECRET!;
|
||||||
@@ -38,7 +39,8 @@ export function getTikTokAuthUrl(state: string, codeChallenge: string): string {
|
|||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
client_key: CLIENT_KEY,
|
client_key: CLIENT_KEY,
|
||||||
response_type: "code",
|
response_type: "code",
|
||||||
scope: "user.info.basic,user.info.stats",
|
// video.list is used as a fallback to compute total views from all videos.
|
||||||
|
scope: "user.info.basic,user.info.stats,video.list",
|
||||||
redirect_uri: getRedirectUri(),
|
redirect_uri: getRedirectUri(),
|
||||||
state,
|
state,
|
||||||
code_challenge: codeChallenge,
|
code_challenge: codeChallenge,
|
||||||
@@ -115,13 +117,57 @@ export interface TikTokUserStats {
|
|||||||
followers: number;
|
followers: number;
|
||||||
likes: number;
|
likes: number;
|
||||||
videoCount: number;
|
videoCount: number;
|
||||||
|
views: number;
|
||||||
|
profileViews: number;
|
||||||
username: string;
|
username: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
avatarUrl: string;
|
avatarUrl: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchTotalVideoViews(accessToken: string): Promise<number> {
|
||||||
|
const fields = "id,view_count";
|
||||||
|
let cursor = 0;
|
||||||
|
let totalViews = 0;
|
||||||
|
|
||||||
|
for (let page = 0; page < 50; page++) {
|
||||||
|
const res = await fetch(`${TIKTOK_VIDEO_LIST_URL}?fields=${fields}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ max_count: 20, cursor }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (!res.ok || data.error?.code !== "ok") {
|
||||||
|
throw new Error(data.error?.message || "TikTok video list fetch failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
const videos = Array.isArray(data.data?.videos) ? data.data.videos : [];
|
||||||
|
for (const video of videos) {
|
||||||
|
totalViews += Number(video?.view_count ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasMore = Boolean(data.data?.has_more);
|
||||||
|
if (!hasMore) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextCursor = Number(data.data?.cursor ?? cursor + videos.length);
|
||||||
|
if (!Number.isFinite(nextCursor) || nextCursor <= cursor) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
cursor = nextCursor;
|
||||||
|
}
|
||||||
|
|
||||||
|
return totalViews;
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchUserStats(accessToken: string, openId: string): Promise<TikTokUserStats> {
|
export async function fetchUserStats(accessToken: string, openId: string): Promise<TikTokUserStats> {
|
||||||
const fields = "follower_count,following_count,likes_count,video_count,display_name,avatar_url";
|
const fields = "follower_count,following_count,likes_count,video_count,video_view_count,profile_view_count,display_name,avatar_url";
|
||||||
const url = `${TIKTOK_USER_INFO_URL}?fields=${fields}`;
|
const url = `${TIKTOK_USER_INFO_URL}?fields=${fields}`;
|
||||||
|
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
@@ -137,11 +183,27 @@ export async function fetchUserStats(accessToken: string, openId: string): Promi
|
|||||||
}
|
}
|
||||||
|
|
||||||
const user = data.data?.user ?? {};
|
const user = data.data?.user ?? {};
|
||||||
|
let views = user.video_view_count ?? user.profile_view_count ?? 0;
|
||||||
|
|
||||||
|
// Some TikTok apps do not receive video_view_count in user.info.stats.
|
||||||
|
// In that case we compute total views from the account video list.
|
||||||
|
if (user.video_view_count == null) {
|
||||||
|
try {
|
||||||
|
const computedViews = await fetchTotalVideoViews(accessToken);
|
||||||
|
if (computedViews > 0) {
|
||||||
|
views = computedViews;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("[TikTok views fallback error]", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
followers: user.follower_count ?? 0,
|
followers: user.follower_count ?? 0,
|
||||||
likes: user.likes_count ?? 0,
|
likes: user.likes_count ?? 0,
|
||||||
videoCount: user.video_count ?? 0,
|
videoCount: user.video_count ?? 0,
|
||||||
|
views,
|
||||||
|
profileViews: user.profile_view_count ?? 0,
|
||||||
username: openId,
|
username: openId,
|
||||||
displayName: user.display_name ?? "",
|
displayName: user.display_name ?? "",
|
||||||
avatarUrl: user.avatar_url ?? "",
|
avatarUrl: user.avatar_url ?? "",
|
||||||
|
|||||||
@@ -2,21 +2,18 @@ FROM node:20-alpine
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copie les fichiers nécessaires
|
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
COPY prisma.config.ts ./
|
COPY prisma.config.ts ./
|
||||||
COPY prisma ./prisma/
|
COPY prisma ./prisma/
|
||||||
COPY tsconfig.json ./
|
COPY tsconfig.json ./
|
||||||
|
|
||||||
# Install deps
|
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
|
|
||||||
# Génère le client Prisma dans app/generated/prisma (output défini dans schema.prisma)
|
|
||||||
RUN npx prisma generate
|
RUN npx prisma generate
|
||||||
|
|
||||||
# Copie le code du worker ET le client Prisma généré
|
|
||||||
COPY worker ./worker/
|
COPY worker ./worker/
|
||||||
COPY app/generated ./app/generated/
|
COPY app/generated ./app/generated/
|
||||||
|
|
||||||
# Lance le worker avec tsx
|
RUN npm install -g tsx
|
||||||
CMD ["npx", "tsx", "worker/snapshot-worker.ts"]
|
|
||||||
|
CMD ["tsx", "worker/snapshot-worker.ts"]
|
||||||
@@ -17,6 +17,7 @@ const prisma = new PrismaClient({ adapter });
|
|||||||
|
|
||||||
const TIKTOK_TOKEN_URL = "https://open.tiktokapis.com/v2/oauth/token/";
|
const TIKTOK_TOKEN_URL = "https://open.tiktokapis.com/v2/oauth/token/";
|
||||||
const TIKTOK_USER_INFO_URL = "https://open.tiktokapis.com/v2/user/info/";
|
const TIKTOK_USER_INFO_URL = "https://open.tiktokapis.com/v2/user/info/";
|
||||||
|
const TIKTOK_VIDEO_LIST_URL = "https://open.tiktokapis.com/v2/video/list/";
|
||||||
const CLIENT_KEY = process.env.TIKTOK_CLIENT_KEY!;
|
const CLIENT_KEY = process.env.TIKTOK_CLIENT_KEY!;
|
||||||
const CLIENT_SECRET = process.env.TIKTOK_CLIENT_SECRET!;
|
const CLIENT_SECRET = process.env.TIKTOK_CLIENT_SECRET!;
|
||||||
const INTERVAL_MS = 60 * 60 * 1000; // 1 heure
|
const INTERVAL_MS = 60 * 60 * 1000; // 1 heure
|
||||||
@@ -57,7 +58,7 @@ async function refreshTikTokToken(refreshTokenStr: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function fetchTikTokStats(accessToken: string) {
|
async function fetchTikTokStats(accessToken: string) {
|
||||||
const fields = "follower_count,likes_count,video_count,display_name";
|
const fields = "follower_count,likes_count,video_count,video_view_count,display_name";
|
||||||
const res = await fetch(`${TIKTOK_USER_INFO_URL}?fields=${fields}`, {
|
const res = await fetch(`${TIKTOK_USER_INFO_URL}?fields=${fields}`, {
|
||||||
headers: { Authorization: `Bearer ${accessToken}` },
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
});
|
});
|
||||||
@@ -68,14 +69,66 @@ async function fetchTikTokStats(accessToken: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const user = data.data?.user ?? {};
|
const user = data.data?.user ?? {};
|
||||||
|
let views = (user.video_view_count ?? user.profile_view_count ?? 0) as number;
|
||||||
|
|
||||||
|
if (user.video_view_count == null) {
|
||||||
|
try {
|
||||||
|
views = await fetchTotalVideoViews(accessToken);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("[worker] fallback views failed:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
followers: (user.follower_count ?? 0) as number,
|
followers: (user.follower_count ?? 0) as number,
|
||||||
likes: (user.likes_count ?? 0) as number,
|
likes: (user.likes_count ?? 0) as number,
|
||||||
videoCount: (user.video_count ?? 0) as number,
|
videoCount: (user.video_count ?? 0) as number,
|
||||||
|
views,
|
||||||
displayName: (user.display_name ?? "") as string,
|
displayName: (user.display_name ?? "") as string,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchTotalVideoViews(accessToken: string): Promise<number> {
|
||||||
|
const fields = "id,view_count";
|
||||||
|
let cursor = 0;
|
||||||
|
let totalViews = 0;
|
||||||
|
|
||||||
|
for (let page = 0; page < 50; page++) {
|
||||||
|
const res = await fetch(`${TIKTOK_VIDEO_LIST_URL}?fields=${fields}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ max_count: 20, cursor }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (!res.ok || data.error?.code !== "ok") {
|
||||||
|
throw new Error(data.error?.message ?? "Video list fetch failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
const videos = Array.isArray(data.data?.videos) ? data.data.videos : [];
|
||||||
|
for (const video of videos) {
|
||||||
|
totalViews += Number(video?.view_count ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasMore = Boolean(data.data?.has_more);
|
||||||
|
if (!hasMore) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextCursor = Number(data.data?.cursor ?? cursor + videos.length);
|
||||||
|
if (!Number.isFinite(nextCursor) || nextCursor <= cursor) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
cursor = nextCursor;
|
||||||
|
}
|
||||||
|
|
||||||
|
return totalViews;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Core job ──────────────────────────────────────────────────
|
// ── Core job ──────────────────────────────────────────────────
|
||||||
|
|
||||||
async function runSnapshots() {
|
async function runSnapshots() {
|
||||||
@@ -136,11 +189,11 @@ async function runSnapshots() {
|
|||||||
followers: stats.followers,
|
followers: stats.followers,
|
||||||
likes: stats.likes,
|
likes: stats.likes,
|
||||||
videoCount: stats.videoCount,
|
videoCount: stats.videoCount,
|
||||||
views: 0,
|
views: stats.views,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`[worker] ✓ userId=${userId} — followers=${stats.followers} likes=${stats.likes} videos=${stats.videoCount}`);
|
console.log(`[worker] ✓ userId=${userId} — followers=${stats.followers} likes=${stats.likes} videos=${stats.videoCount} views=${stats.views}`);
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`[worker] ✗ erreur userId=${userId}:`, err);
|
console.error(`[worker] ✗ erreur userId=${userId}:`, err);
|
||||||
|
|||||||
Reference in New Issue
Block a user