refact
This commit is contained in:
@@ -73,6 +73,11 @@ export function LineChartMultiple({
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardDescription>
|
||||
Total requests of {type1} and {type2}.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ChartContainer config={chartConfig}>
|
||||
<LineChart
|
||||
@@ -90,9 +95,15 @@ export function LineChartMultiple({
|
||||
tickLine={false}
|
||||
axisLine={true}
|
||||
tickMargin={2}
|
||||
// tickFormatter={(value) => value.slice(5, -1)}
|
||||
tickFormatter={(value) => {
|
||||
const date = new Date(value);
|
||||
return date.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<YAxis axisLine={false} tickLine={false} />
|
||||
<YAxis width={20} axisLine={false} tickLine={false} />
|
||||
<ChartTooltip cursor={false} content={<ChartTooltipContent />} />
|
||||
<Line
|
||||
dataKey="source1"
|
||||
@@ -111,11 +122,6 @@ export function LineChartMultiple({
|
||||
</LineChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
<CardFooter className="flex-col gap-2 text-pretty text-center text-sm">
|
||||
<div className="leading-none text-muted-foreground">
|
||||
Showing total requests of {type1} and {type2}.
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -288,7 +288,6 @@ export default function UserRecordsList({ user, action }: RecordListProps) {
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<ul className="list-disc px-3">
|
||||
{/* 无序列表的dot */}
|
||||
<li>The target is currently inaccessible.</li>
|
||||
<li>Please check the target and try again.</li>
|
||||
<li>
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import {
|
||||
getScrapeStatsByTypeAndUserId,
|
||||
getScrapeStatsByUserId,
|
||||
getScrapeStatsByUserId1,
|
||||
} from "@/lib/dto/scrape";
|
||||
|
||||
import { LineChartMultiple } from "../../admin/line-chart-multiple";
|
||||
import { DailyPVUVChart } from "./daily-chart";
|
||||
import LogsTable from "./logs";
|
||||
|
||||
export default async function DashboardScrapeCharts({ id }: { id: string }) {
|
||||
const screenshot_stats = await getScrapeStatsByTypeAndUserId(
|
||||
"screenshot",
|
||||
id,
|
||||
);
|
||||
const meta_stats = await getScrapeStatsByTypeAndUserId("meta-info", id);
|
||||
const md_stats = await getScrapeStatsByTypeAndUserId("markdown", id);
|
||||
const text_stats = await getScrapeStatsByTypeAndUserId("text", id);
|
||||
|
||||
const all_logs = await getScrapeStatsByUserId1(id);
|
||||
|
||||
return (
|
||||
<>
|
||||
<h2 className="my-1 text-xl font-semibold">Request Statistics</h2>
|
||||
<DailyPVUVChart data={all_logs} />
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<LineChartMultiple
|
||||
chartData={screenshot_stats.concat(meta_stats)}
|
||||
type1="screenshot"
|
||||
type2="meta-info"
|
||||
/>
|
||||
<LineChartMultiple
|
||||
chartData={md_stats.concat(text_stats)}
|
||||
type1="markdown"
|
||||
type2="text"
|
||||
/>
|
||||
</div>
|
||||
<h2 className="my-1 text-xl font-semibold">Request Logs</h2>
|
||||
<LogsTable userId={id} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import Link from "next/link";
|
||||
import { ScrapeMeta } from "@prisma/client";
|
||||
import { Area, AreaChart, CartesianGrid, XAxis } from "recharts";
|
||||
|
||||
import { isLink, removeUrlSuffix, timeAgo } from "@/lib/utils";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from "@/components/ui/chart";
|
||||
|
||||
const chartConfig = {
|
||||
request: {
|
||||
label: "Requests",
|
||||
color: "hsl(var(--chart-2))",
|
||||
},
|
||||
ip: {
|
||||
label: "IP",
|
||||
color: "hsl(var(--chart-1))",
|
||||
},
|
||||
};
|
||||
|
||||
function processUrlMeta(urlMetaArray: ScrapeMeta[]) {
|
||||
const dailyData: { [key: string]: { clicks: number; ips: Set<string> } } = {};
|
||||
|
||||
urlMetaArray.forEach((meta) => {
|
||||
const createdDate = new Date(meta.createdAt).toISOString().split("T")[0];
|
||||
const updatedDate = new Date(meta.updatedAt).toISOString().split("T")[0];
|
||||
|
||||
// Record for created date
|
||||
if (!dailyData[createdDate]) {
|
||||
dailyData[createdDate] = { clicks: 0, ips: new Set<string>() };
|
||||
}
|
||||
dailyData[createdDate].clicks += 1;
|
||||
dailyData[createdDate].ips.add(meta.ip);
|
||||
|
||||
// If updated date is different, record additional clicks on that date
|
||||
if (createdDate !== updatedDate) {
|
||||
if (!dailyData[updatedDate]) {
|
||||
dailyData[updatedDate] = { clicks: 0, ips: new Set<string>() };
|
||||
}
|
||||
dailyData[updatedDate].clicks += meta.click - 1; // Subtract the initial click
|
||||
dailyData[updatedDate].ips.add(meta.ip);
|
||||
}
|
||||
});
|
||||
|
||||
return Object.entries(dailyData).map(([date, data]) => ({
|
||||
date,
|
||||
clicks: data.clicks,
|
||||
uniqueIPs: data.ips.size,
|
||||
ips: Array.from(data.ips),
|
||||
}));
|
||||
}
|
||||
|
||||
function calculateUVAndPV(logs: ScrapeMeta[]) {
|
||||
const uniqueIps = new Set<string>();
|
||||
let totalClicks = 0;
|
||||
|
||||
logs.forEach((log) => {
|
||||
uniqueIps.add(log.ip);
|
||||
totalClicks += log.click;
|
||||
});
|
||||
|
||||
return {
|
||||
ip: uniqueIps.size,
|
||||
request: totalClicks,
|
||||
};
|
||||
}
|
||||
|
||||
interface Stat {
|
||||
dimension: string;
|
||||
clicks: number;
|
||||
percentage: string;
|
||||
}
|
||||
|
||||
export function DailyPVUVChart({ data }: { data: ScrapeMeta[] }) {
|
||||
const [activeChart, setActiveChart] =
|
||||
React.useState<keyof typeof chartConfig>("request");
|
||||
|
||||
const processedData = processUrlMeta(data)
|
||||
.map((entry) => ({
|
||||
date: entry.date,
|
||||
request: entry.clicks,
|
||||
ip: new Set(entry.ips).size,
|
||||
}))
|
||||
.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
|
||||
|
||||
const dataTotal = calculateUVAndPV(data);
|
||||
|
||||
const latestEntry = data[data.length - 1];
|
||||
const latestDate = timeAgo(latestEntry.updatedAt);
|
||||
const latestFrom = latestEntry.type;
|
||||
|
||||
return (
|
||||
<Card className="">
|
||||
<CardHeader className="flex flex-col items-stretch space-y-0 border-b p-0 sm:flex-row">
|
||||
<div className="flex flex-1 flex-col justify-center gap-1 px-6 py-2 sm:py-3">
|
||||
<CardTitle>Total Requests of APIs</CardTitle>
|
||||
<CardDescription>
|
||||
Last request from <strong>{latestFrom}</strong> api about{" "}
|
||||
{latestDate}.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex">
|
||||
{["request", "ip"].map((key) => {
|
||||
const chart = key as keyof typeof chartConfig;
|
||||
return (
|
||||
<button
|
||||
key={chart}
|
||||
data-active={activeChart === chart}
|
||||
className="relative z-30 flex flex-1 flex-col items-center justify-center gap-1 border-t px-6 py-2 text-left even:border-l data-[active=true]:bg-muted/50 sm:border-l sm:border-t-0 sm:px-8 sm:py-3"
|
||||
onClick={() => setActiveChart(chart)}
|
||||
>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{chartConfig[chart].label}
|
||||
</span>
|
||||
<span className="text-lg font-bold leading-none">
|
||||
{dataTotal[key as keyof typeof dataTotal].toLocaleString()}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="px-2 sm:p-6">
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="aspect-auto h-[225px] w-full"
|
||||
>
|
||||
<AreaChart
|
||||
accessibilityLayer
|
||||
data={processedData}
|
||||
margin={{
|
||||
top: 20,
|
||||
right: 30,
|
||||
left: 20,
|
||||
bottom: 5,
|
||||
}}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="colorUv" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop
|
||||
offset="5%"
|
||||
stopColor={`var(--color-ip)`}
|
||||
stopOpacity={0.8}
|
||||
/>
|
||||
<stop
|
||||
offset="95%"
|
||||
stopColor={`var(--color-ip)`}
|
||||
stopOpacity={0}
|
||||
/>
|
||||
</linearGradient>
|
||||
<linearGradient id="colorPv" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop
|
||||
offset="5%"
|
||||
stopColor={`var(--color-request)`}
|
||||
stopOpacity={0.8}
|
||||
/>
|
||||
<stop
|
||||
offset="95%"
|
||||
stopColor={`var(--color-request)`}
|
||||
stopOpacity={0}
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
minTickGap={32}
|
||||
tickFormatter={(value) => {
|
||||
const date = new Date(value);
|
||||
return date.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<ChartTooltip
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
className="w-[150px]"
|
||||
nameKey="views"
|
||||
labelFormatter={(value) => {
|
||||
return new Date(value).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{/* <Bar dataKey="ip" fill={`var(--color-ip)`} stackId="a" />
|
||||
<Bar dataKey="request" fill={`var(--color-request)`} stackId="a" /> */}
|
||||
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="ip"
|
||||
stroke={`var(--color-ip)`}
|
||||
fillOpacity={1}
|
||||
fill="url(#colorUv)"
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="request"
|
||||
stroke={`var(--color-request)`}
|
||||
fillOpacity={1}
|
||||
fill="url(#colorPv)"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
|
||||
{/* <VisSingleContainer data={{ areas: areaData }}>
|
||||
<VisTopoJSONMap topojson={WorldMapTopoJSON} />
|
||||
<VisTooltip triggers={triggers} />
|
||||
</VisSingleContainer> */}
|
||||
{/*
|
||||
<div className="my-5 grid grid-cols-1 gap-6 sm:grid-cols-2">
|
||||
{refererStats.length > 0 && (
|
||||
<StatsList data={refererStats} title="Referrers" />
|
||||
)}
|
||||
{cityStats.length > 0 && (
|
||||
<StatsList data={cityStats} title="Cities" />
|
||||
)}
|
||||
{browserStats.length > 0 && (
|
||||
<StatsList data={browserStats} title="Browsers" />
|
||||
)}
|
||||
{deviceStats.length > 0 && (
|
||||
<StatsList data={deviceStats} title="Devices" />
|
||||
)}
|
||||
</div> */}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatsList({ data, title }: { data: Stat[]; title: string }) {
|
||||
return (
|
||||
<div className="rounded-lg border p-4">
|
||||
<h1 className="text-lg font-bold">{title}</h1>
|
||||
{data.slice(0, 10).map((ref) => (
|
||||
<div className="mt-1" key={ref.dimension}>
|
||||
<div className="mb-0.5 flex items-center justify-between text-sm">
|
||||
{isLink(ref.dimension) ? (
|
||||
<Link
|
||||
className="truncate font-medium hover:opacity-70 hover:after:content-['↗']"
|
||||
href={ref.dimension}
|
||||
>
|
||||
{removeUrlSuffix(ref.dimension)}
|
||||
</Link>
|
||||
) : (
|
||||
<p className="font-medium">{decodeURIComponent(ref.dimension)}</p>
|
||||
)}
|
||||
<p className="text-slate-500">
|
||||
{ref.clicks} ({ref.percentage})
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-full rounded-lg bg-neutral-200 dark:bg-neutral-600">
|
||||
<div
|
||||
className="rounded-lg bg-blue-500/90 px-0.5 py-1 leading-none transition-all duration-300"
|
||||
style={{
|
||||
width: `${ref.percentage}`,
|
||||
opacity: parseFloat(ref.percentage) / 10,
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { DashboardHeader } from "@/components/dashboard/header";
|
||||
export default function DashboardRecordsLoading() {
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader heading="DNS Records" text="" />
|
||||
<DashboardHeader heading="Scraping API" text="" />
|
||||
<Skeleton className="h-full w-full rounded-lg" />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { RefreshCwIcon } from "lucide-react";
|
||||
import useSWR from "swr";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Icons } from "@/components/shared/icons";
|
||||
import { PaginationWrapper } from "@/components/shared/pagination";
|
||||
|
||||
export interface LogsTableData {
|
||||
id: string;
|
||||
type: string;
|
||||
ip: string;
|
||||
link: string;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
const fetcher = (url: string) => fetch(url).then((res) => res.json());
|
||||
|
||||
const getLogsUrl = (
|
||||
userId: string,
|
||||
page: number,
|
||||
filters: { type: string; ip: string },
|
||||
) => {
|
||||
const params = new URLSearchParams({
|
||||
userId,
|
||||
page: page.toString(),
|
||||
...(filters.type && { type: filters.type }),
|
||||
...(filters.ip && { ip: filters.ip }),
|
||||
});
|
||||
return `/api/scraping/logs?${params}`;
|
||||
};
|
||||
|
||||
const LogsTable = ({ userId }) => {
|
||||
const [page, setPage] = useState(1);
|
||||
const [filters, setFilters] = useState({
|
||||
type: "",
|
||||
ip: "",
|
||||
});
|
||||
|
||||
const { data, error, isLoading, mutate } = useSWR(
|
||||
getLogsUrl(userId, page, filters),
|
||||
fetcher,
|
||||
{
|
||||
keepPreviousData: true,
|
||||
revalidateOnFocus: false,
|
||||
},
|
||||
);
|
||||
|
||||
const { data: nextPageData } = useSWR(
|
||||
data?.hasMore ? getLogsUrl(userId, page + 1, filters) : null,
|
||||
fetcher,
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
},
|
||||
);
|
||||
|
||||
const logs = data?.logs || [];
|
||||
|
||||
const handleFilterChange = (key, value) => {
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
[key]: value,
|
||||
}));
|
||||
setPage(1);
|
||||
mutate();
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-4 text-center text-red-500">
|
||||
Failed to load logs. Please try again later.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleRefresh = () => {
|
||||
mutate();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<Input
|
||||
placeholder="Filter by type..."
|
||||
value={filters.type}
|
||||
onChange={(e) => handleFilterChange("type", e.target.value)}
|
||||
className="max-w-xs"
|
||||
/>
|
||||
<Input
|
||||
placeholder="Filter by IP..."
|
||||
value={filters.ip}
|
||||
onChange={(e) => handleFilterChange("ip", e.target.value)}
|
||||
className="max-w-xs"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleRefresh}
|
||||
disabled={isLoading}
|
||||
className="ml-auto"
|
||||
>
|
||||
{isLoading ? (
|
||||
<RefreshCwIcon className={`size-4 animate-spin`} />
|
||||
) : (
|
||||
<RefreshCwIcon className={`size-4`} />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader className="bg-muted">
|
||||
<TableRow className="">
|
||||
<TableHead className="">Date</TableHead>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead className="">IP</TableHead>
|
||||
<TableHead>Link</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading && logs.length === 0
|
||||
? Array.from({ length: 5 }).map((_, i) => (
|
||||
<TableRow key={i}>
|
||||
<TableCell className="hidden sm:inline-block">
|
||||
<Skeleton className="h-2 w-[100px]" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-2 w-[80px]" />
|
||||
</TableCell>
|
||||
<TableCell className="hidden sm:inline-block">
|
||||
<Skeleton className="h-2 w-[120px]" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-2 w-[200px]" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
: logs.map((log) => (
|
||||
<TableRow className="text-xs hover:bg-muted" key={log.id}>
|
||||
<TableCell className="hidden p-2 sm:inline-block">
|
||||
{new Date(log.createdAt).toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell className="p-2">{log.type}</TableCell>
|
||||
<TableCell className="hidden p-2 sm:inline-block">
|
||||
{log.ip}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-md truncate p-2">
|
||||
{log.link}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{data && Math.ceil(data.total / 20) > 1 && (
|
||||
<PaginationWrapper
|
||||
total={Math.ceil(data.total / 20)}
|
||||
currentPage={page}
|
||||
setCurrentPage={setPage}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default LogsTable;
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { DashboardHeader } from "@/components/dashboard/header";
|
||||
|
||||
export default function DashboardRecordsLoading() {
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader heading="Scraping API" text="" />
|
||||
<Skeleton className="h-full w-full rounded-lg" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
import { constructMetadata } from "@/lib/utils";
|
||||
import { DashboardHeader } from "@/components/dashboard/header";
|
||||
|
||||
import { MarkdownScraping, TextScraping } from "../scrapes";
|
||||
|
||||
export const metadata = constructMetadata({
|
||||
title: "Url to Markdown API - WR.DO",
|
||||
description:
|
||||
"Quickly extract website content and convert it to Markdown format",
|
||||
});
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const user = await getCurrentUser();
|
||||
|
||||
if (!user?.id) redirect("/login");
|
||||
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader
|
||||
heading="Url to Markdown API"
|
||||
text="Quickly extract website content and convert it to Markdown format. It's free and unlimited to use!"
|
||||
link="/docs/open-api/markdown"
|
||||
linkText="Markdown API."
|
||||
/>
|
||||
<MarkdownScraping user={{ id: user.id, apiKey: user.apiKey }} />
|
||||
<TextScraping user={{ id: user.id, apiKey: user.apiKey }} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { DashboardHeader } from "@/components/dashboard/header";
|
||||
|
||||
export default function DashboardRecordsLoading() {
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader heading="Scraping API" text="" />
|
||||
<Skeleton className="h-full w-full rounded-lg" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
import { constructMetadata } from "@/lib/utils";
|
||||
import { DashboardHeader } from "@/components/dashboard/header";
|
||||
|
||||
import DashboardScrapeCharts from "../charts";
|
||||
import { MetaScraping } from "../scrapes";
|
||||
|
||||
export const metadata = constructMetadata({
|
||||
title: "Url to Meta API - WR.DO",
|
||||
description: "Quickly extract valuable structured website data",
|
||||
});
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const user = await getCurrentUser();
|
||||
|
||||
if (!user?.id) redirect("/login");
|
||||
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader
|
||||
heading="Url to Meta API"
|
||||
text="Quickly extract valuable structured website data. It's free and unlimited to use!"
|
||||
link="/docs/open-api/meta-info"
|
||||
linkText="Meta Info API."
|
||||
/>
|
||||
<MetaScraping user={{ id: user.id, apiKey: user.apiKey }} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,237 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { User } from "@prisma/client";
|
||||
import JsonView from "@uiw/react-json-view";
|
||||
import { githubLightTheme } from "@uiw/react-json-view/githubLight";
|
||||
import { vscodeTheme } from "@uiw/react-json-view/vscode";
|
||||
import { useTheme } from "next-themes";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import BlurImage from "@/components/shared/blur-image";
|
||||
|
||||
export interface MetaScrapingProps {
|
||||
title: string;
|
||||
description: string;
|
||||
image: string;
|
||||
icon: string;
|
||||
url: string;
|
||||
lang: string;
|
||||
author: string;
|
||||
timestamp: string;
|
||||
payload: string;
|
||||
}
|
||||
|
||||
export default function MetaScraping({
|
||||
user,
|
||||
}: {
|
||||
user: { id: string; apiKey: string };
|
||||
}) {
|
||||
const { theme } = useTheme();
|
||||
const [currentLink, setCurrentLink] = useState("wr.do");
|
||||
const [protocol, setProtocol] = useState("https://");
|
||||
const [metaInfo, setMetaInfo] = useState<MetaScrapingProps>({
|
||||
title: "",
|
||||
description: "",
|
||||
image: "",
|
||||
icon: "",
|
||||
url: "",
|
||||
lang: "",
|
||||
author: "",
|
||||
timestamp: "",
|
||||
payload: "",
|
||||
});
|
||||
const [isScraping, setIsScraping] = useState(false);
|
||||
|
||||
const [isShoting, setIsShoting] = useState(false);
|
||||
const [currentScreenshotLink, setCurrentScreenshotLink] =
|
||||
useState("vmail.dev");
|
||||
const [screenshotInfo, setScreenshotInfo] = useState({
|
||||
tmp_url: "",
|
||||
payload: "",
|
||||
});
|
||||
|
||||
const handleScrapingMeta = async () => {
|
||||
if (currentLink) {
|
||||
setIsScraping(true);
|
||||
const res = await fetch(
|
||||
`/api/scraping/meta?url=${protocol}${currentLink}&key=${user.apiKey}`,
|
||||
);
|
||||
if (!res.ok || res.status !== 200) {
|
||||
const data = await res.json();
|
||||
toast.error(data.statusText);
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setMetaInfo(data);
|
||||
toast.success("Success!");
|
||||
}
|
||||
setIsScraping(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleScrapingScreenshot = async () => {
|
||||
if (currentScreenshotLink) {
|
||||
setIsShoting(true);
|
||||
const payload = `/api/scraping/screenshot?url=${protocol}${currentScreenshotLink}&key=${user.apiKey}`;
|
||||
const res = await fetch(payload);
|
||||
if (!res.ok || res.status !== 200) {
|
||||
const data = await res.json();
|
||||
toast.error(data.statusText);
|
||||
} else {
|
||||
const blob = await res.blob();
|
||||
const imageUrl = URL.createObjectURL(blob);
|
||||
setScreenshotInfo({
|
||||
tmp_url: imageUrl,
|
||||
payload: `${window.location.origin}${payload}`,
|
||||
});
|
||||
toast.success("Success!");
|
||||
}
|
||||
setIsShoting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="bg-gray-50 dark:bg-gray-900">
|
||||
<CardHeader>
|
||||
<CardTitle>Url to Screenshot</CardTitle>
|
||||
<CardDescription>
|
||||
Automate your website screenshots and turn them into stunning
|
||||
visuals for your applications.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center">
|
||||
<Select
|
||||
onValueChange={(value: string) => {
|
||||
setProtocol(value);
|
||||
}}
|
||||
name="protocol"
|
||||
defaultValue="https://"
|
||||
>
|
||||
<SelectTrigger className="h-10 w-24 rounded-r-none bg-transparent shadow-inner">
|
||||
<SelectValue placeholder="Protocol" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem key="https" value="https://">
|
||||
https://
|
||||
</SelectItem>
|
||||
<SelectItem key="http" value="http://">
|
||||
http://
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="www.example.com"
|
||||
className="h-10 rounded-none border focus:border-primary active:border-primary"
|
||||
value={currentScreenshotLink}
|
||||
size={100}
|
||||
onChange={(e) => setCurrentScreenshotLink(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
variant="blue"
|
||||
onClick={handleScrapingScreenshot}
|
||||
disabled={isShoting}
|
||||
className="rounded-l-none"
|
||||
>
|
||||
{isShoting ? "Scraping..." : "Send"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 rounded-md border p-3">
|
||||
<JsonView
|
||||
className="max-w-[400px] overflow-hidden"
|
||||
style={theme === "dark" ? vscodeTheme : githubLightTheme}
|
||||
value={screenshotInfo}
|
||||
displayObjectSize={false}
|
||||
displayDataTypes={false}
|
||||
// shortenTextAfterLength={50}
|
||||
/>
|
||||
{screenshotInfo.tmp_url && (
|
||||
<BlurImage
|
||||
src={screenshotInfo.tmp_url}
|
||||
alt="ligth preview landing"
|
||||
className="my-4 flex rounded-md border object-contain object-center shadow-md"
|
||||
width={1500}
|
||||
height={750}
|
||||
priority
|
||||
// placeholder="blur"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-gray-50 dark:bg-gray-900">
|
||||
<CardHeader>
|
||||
<CardTitle>Website Meta Scrape</CardTitle>
|
||||
<CardDescription>Scrape the meta data of a website.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center">
|
||||
<Select
|
||||
onValueChange={(value: string) => {
|
||||
setProtocol(value);
|
||||
}}
|
||||
name="protocol"
|
||||
defaultValue={"https://"}
|
||||
>
|
||||
<SelectTrigger className="h-10 w-24 rounded-r-none bg-transparent shadow-inner">
|
||||
<SelectValue placeholder="Protocol" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem key="https" value="https://">
|
||||
https://
|
||||
</SelectItem>
|
||||
<SelectItem key="http" value="http://">
|
||||
http://
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="www.example.com"
|
||||
className="h-10 rounded-none border focus:border-primary active:border-primary"
|
||||
value={currentLink}
|
||||
size={100}
|
||||
onChange={(e) => setCurrentLink(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
variant="blue"
|
||||
onClick={handleScrapingMeta}
|
||||
disabled={isScraping}
|
||||
className="rounded-l-none"
|
||||
>
|
||||
{isScraping ? "Scraping..." : "Send"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 rounded-md border p-3">
|
||||
<JsonView
|
||||
style={theme === "dark" ? vscodeTheme : githubLightTheme}
|
||||
value={metaInfo}
|
||||
displayObjectSize={false}
|
||||
displayDataTypes={false}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -2,9 +2,10 @@ import { redirect } from "next/navigation";
|
||||
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
import { constructMetadata } from "@/lib/utils";
|
||||
import { ScrapeInfoCard } from "@/components/dashboard/dashboard-info-card";
|
||||
import { DashboardHeader } from "@/components/dashboard/header";
|
||||
|
||||
import MetaScraping from "./meta-scraping";
|
||||
import DashboardScrapeCharts from "./charts";
|
||||
|
||||
export const metadata = constructMetadata({
|
||||
title: "Scraping API - WR.DO",
|
||||
@@ -24,7 +25,46 @@ export default async function DashboardPage() {
|
||||
link="/docs/open-api"
|
||||
linkText="Open API."
|
||||
/>
|
||||
<MetaScraping user={{ id: user.id, apiKey: user.apiKey }} />
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<ScrapeInfoCard
|
||||
userId={user.id}
|
||||
title="Url to Screenshot"
|
||||
desc="Take a screenshot of the webpage."
|
||||
link="/dashboard/scrape/screenshot"
|
||||
icon="camera"
|
||||
/>
|
||||
<ScrapeInfoCard
|
||||
userId={user.id}
|
||||
title="Url to Meta Info"
|
||||
desc="Extract website metadata."
|
||||
link="/dashboard/scrape/meta-info"
|
||||
icon="globe"
|
||||
/>
|
||||
<ScrapeInfoCard
|
||||
userId={user.id}
|
||||
title="Url to QR Code"
|
||||
desc="Generate QR Code from URL."
|
||||
link="/dashboard/scrape/qrcode"
|
||||
icon="qrcode"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<ScrapeInfoCard
|
||||
userId={user.id}
|
||||
title="Url to Markdown"
|
||||
desc="Convert website content to Markdown format."
|
||||
link="/dashboard/scrape/markdown"
|
||||
icon="heading1"
|
||||
/>
|
||||
<ScrapeInfoCard
|
||||
userId={user.id}
|
||||
title="Url to Text"
|
||||
desc="Extract website text."
|
||||
link="/dashboard/scrape/markdown"
|
||||
icon="fileText"
|
||||
/>
|
||||
</div>
|
||||
<DashboardScrapeCharts id={user.id} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,487 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import JsonView from "@uiw/react-json-view";
|
||||
import { githubLightTheme } from "@uiw/react-json-view/githubLight";
|
||||
import { vscodeTheme } from "@uiw/react-json-view/vscode";
|
||||
import { useTheme } from "next-themes";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import BlurImage from "@/components/shared/blur-image";
|
||||
|
||||
export interface MetaScrapingProps {
|
||||
title: string;
|
||||
description: string;
|
||||
image: string;
|
||||
icon: string;
|
||||
url: string;
|
||||
lang: string;
|
||||
author: string;
|
||||
timestamp: string;
|
||||
payload: string;
|
||||
}
|
||||
|
||||
export interface MarkdownScrapingProps {
|
||||
url: string;
|
||||
content: string;
|
||||
format: string;
|
||||
timestamp: string;
|
||||
payload: string;
|
||||
}
|
||||
|
||||
export function ScreenshotScraping({
|
||||
user,
|
||||
}: {
|
||||
user: { id: string; apiKey: string };
|
||||
}) {
|
||||
const { theme } = useTheme();
|
||||
const [protocol, setProtocol] = useState("https://");
|
||||
|
||||
const [isShoting, setIsShoting] = useState(false);
|
||||
const [currentScreenshotLink, setCurrentScreenshotLink] =
|
||||
useState("vmail.dev");
|
||||
const [screenshotInfo, setScreenshotInfo] = useState({
|
||||
tmp_url: "",
|
||||
payload: "",
|
||||
});
|
||||
|
||||
const handleScrapingScreenshot = async () => {
|
||||
if (currentScreenshotLink) {
|
||||
setIsShoting(true);
|
||||
const payload = `/api/scraping/screenshot?url=${protocol}${currentScreenshotLink}&key=${user.apiKey}`;
|
||||
const res = await fetch(payload);
|
||||
if (!res.ok || res.status !== 200) {
|
||||
const data = await res.json();
|
||||
toast.error(data.statusText);
|
||||
} else {
|
||||
const blob = await res.blob();
|
||||
const imageUrl = URL.createObjectURL(blob);
|
||||
setScreenshotInfo({
|
||||
tmp_url: imageUrl,
|
||||
payload: `${window.location.origin}${payload}`,
|
||||
});
|
||||
toast.success("Success!");
|
||||
}
|
||||
setIsShoting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<CodeLight content={`https://wr.do/api/scraping/screenshot`} />
|
||||
<Card className="bg-gray-50 dark:bg-gray-900">
|
||||
<CardHeader>
|
||||
<CardTitle>Playground</CardTitle>
|
||||
<CardDescription>
|
||||
Automate your website screenshots and turn them into stunning
|
||||
visuals for your applications.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center">
|
||||
<Select
|
||||
onValueChange={(value: string) => {
|
||||
setProtocol(value);
|
||||
}}
|
||||
name="protocol"
|
||||
defaultValue="https://"
|
||||
>
|
||||
<SelectTrigger className="h-10 w-24 rounded-r-none bg-transparent shadow-inner">
|
||||
<SelectValue placeholder="Protocol" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem key="https" value="https://">
|
||||
https://
|
||||
</SelectItem>
|
||||
<SelectItem key="http" value="http://">
|
||||
http://
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="www.example.com"
|
||||
className="h-10 rounded-none border focus:border-primary active:border-primary"
|
||||
value={currentScreenshotLink}
|
||||
size={100}
|
||||
onChange={(e) => setCurrentScreenshotLink(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
variant="blue"
|
||||
onClick={handleScrapingScreenshot}
|
||||
disabled={isShoting}
|
||||
className="rounded-l-none"
|
||||
>
|
||||
{isShoting ? "Scraping..." : "Send"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 rounded-md border p-3">
|
||||
<JsonView
|
||||
className="max-w-[400px] overflow-hidden"
|
||||
style={theme === "dark" ? vscodeTheme : githubLightTheme}
|
||||
value={screenshotInfo}
|
||||
displayObjectSize={false}
|
||||
displayDataTypes={false}
|
||||
// shortenTextAfterLength={50}
|
||||
/>
|
||||
{screenshotInfo.tmp_url && (
|
||||
<BlurImage
|
||||
src={screenshotInfo.tmp_url}
|
||||
alt="ligth preview landing"
|
||||
className="my-4 flex rounded-md border object-contain object-center shadow-md"
|
||||
width={1500}
|
||||
height={750}
|
||||
priority
|
||||
// placeholder="blur"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function MetaScraping({
|
||||
user,
|
||||
}: {
|
||||
user: { id: string; apiKey: string };
|
||||
}) {
|
||||
const { theme } = useTheme();
|
||||
const [currentLink, setCurrentLink] = useState("wr.do");
|
||||
const [protocol, setProtocol] = useState("https://");
|
||||
const [metaInfo, setMetaInfo] = useState<MetaScrapingProps>({
|
||||
title: "",
|
||||
description: "",
|
||||
image: "",
|
||||
icon: "",
|
||||
url: "",
|
||||
lang: "",
|
||||
author: "",
|
||||
timestamp: "",
|
||||
payload: "",
|
||||
});
|
||||
const [isScraping, setIsScraping] = useState(false);
|
||||
|
||||
const handleScrapingMeta = async () => {
|
||||
if (currentLink) {
|
||||
setIsScraping(true);
|
||||
const res = await fetch(
|
||||
`/api/scraping/meta?url=${protocol}${currentLink}&key=${user.apiKey}`,
|
||||
);
|
||||
if (!res.ok || res.status !== 200) {
|
||||
const data = await res.json();
|
||||
toast.error(data.statusText);
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setMetaInfo(data);
|
||||
toast.success("Success!");
|
||||
}
|
||||
setIsScraping(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<CodeLight content={`https://wr.do/api/scraping/meta`} />
|
||||
<Card className="bg-gray-50 dark:bg-gray-900">
|
||||
<CardHeader>
|
||||
<CardTitle>Playground</CardTitle>
|
||||
<CardDescription>Scrape the meta data of a website.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center">
|
||||
<Select
|
||||
onValueChange={(value: string) => {
|
||||
setProtocol(value);
|
||||
}}
|
||||
name="protocol"
|
||||
defaultValue={"https://"}
|
||||
>
|
||||
<SelectTrigger className="h-10 w-24 rounded-r-none bg-transparent shadow-inner">
|
||||
<SelectValue placeholder="Protocol" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem key="https" value="https://">
|
||||
https://
|
||||
</SelectItem>
|
||||
<SelectItem key="http" value="http://">
|
||||
http://
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="www.example.com"
|
||||
className="h-10 rounded-none border focus:border-primary active:border-primary"
|
||||
value={currentLink}
|
||||
size={100}
|
||||
onChange={(e) => setCurrentLink(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
variant="blue"
|
||||
onClick={handleScrapingMeta}
|
||||
disabled={isScraping}
|
||||
className="rounded-l-none"
|
||||
>
|
||||
{isScraping ? "Scraping..." : "Send"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 rounded-md border p-3">
|
||||
<JsonView
|
||||
style={theme === "dark" ? vscodeTheme : githubLightTheme}
|
||||
value={metaInfo}
|
||||
displayObjectSize={false}
|
||||
displayDataTypes={false}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function MarkdownScraping({
|
||||
user,
|
||||
}: {
|
||||
user: { id: string; apiKey: string };
|
||||
}) {
|
||||
const { theme } = useTheme();
|
||||
const [currentLink, setCurrentLink] = useState("wr.do");
|
||||
const [protocol, setProtocol] = useState("https://");
|
||||
const [metaInfo, setMetaInfo] = useState<MarkdownScrapingProps>({
|
||||
url: "",
|
||||
content: "",
|
||||
format: "",
|
||||
timestamp: "",
|
||||
payload: "",
|
||||
});
|
||||
const [isScraping, setIsScraping] = useState(false);
|
||||
|
||||
const handleScrapingMeta = async () => {
|
||||
if (currentLink) {
|
||||
setIsScraping(true);
|
||||
const res = await fetch(
|
||||
`/api/scraping/markdown?url=${protocol}${currentLink}&key=${user.apiKey}`,
|
||||
);
|
||||
if (!res.ok || res.status !== 200) {
|
||||
const data = await res.json();
|
||||
toast.error(data.statusText);
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setMetaInfo(data);
|
||||
toast.success("Success!");
|
||||
}
|
||||
setIsScraping(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<CodeLight content={`https://wr.do/api/scraping/markdown`} />
|
||||
<Card className="bg-gray-50 dark:bg-gray-900">
|
||||
<CardHeader>
|
||||
<CardTitle>Markdown</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center">
|
||||
<Select
|
||||
onValueChange={(value: string) => {
|
||||
setProtocol(value);
|
||||
}}
|
||||
name="protocol"
|
||||
defaultValue={"https://"}
|
||||
>
|
||||
<SelectTrigger className="h-10 w-24 rounded-r-none bg-transparent shadow-inner">
|
||||
<SelectValue placeholder="Protocol" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem key="https" value="https://">
|
||||
https://
|
||||
</SelectItem>
|
||||
<SelectItem key="http" value="http://">
|
||||
http://
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="www.example.com"
|
||||
className="h-10 rounded-none border focus:border-primary active:border-primary"
|
||||
value={currentLink}
|
||||
size={100}
|
||||
onChange={(e) => setCurrentLink(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
variant="blue"
|
||||
onClick={handleScrapingMeta}
|
||||
disabled={isScraping}
|
||||
className="rounded-l-none"
|
||||
>
|
||||
{isScraping ? "Scraping..." : "Send"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 rounded-md border p-3">
|
||||
<JsonView
|
||||
style={theme === "dark" ? vscodeTheme : githubLightTheme}
|
||||
value={metaInfo}
|
||||
displayObjectSize={false}
|
||||
displayDataTypes={false}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function TextScraping({
|
||||
user,
|
||||
}: {
|
||||
user: { id: string; apiKey: string };
|
||||
}) {
|
||||
const { theme } = useTheme();
|
||||
const [currentLink, setCurrentLink] = useState("wr.do");
|
||||
const [protocol, setProtocol] = useState("https://");
|
||||
const [metaInfo, setMetaInfo] = useState<MarkdownScrapingProps>({
|
||||
url: "",
|
||||
content: "",
|
||||
format: "",
|
||||
timestamp: "",
|
||||
payload: "",
|
||||
});
|
||||
const [isScraping, setIsScraping] = useState(false);
|
||||
|
||||
const handleScrapingMeta = async () => {
|
||||
if (currentLink) {
|
||||
setIsScraping(true);
|
||||
const res = await fetch(
|
||||
`/api/scraping/text?url=${protocol}${currentLink}&key=${user.apiKey}`,
|
||||
);
|
||||
if (!res.ok || res.status !== 200) {
|
||||
const data = await res.json();
|
||||
toast.error(data.statusText);
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setMetaInfo(data);
|
||||
toast.success("Success!");
|
||||
}
|
||||
setIsScraping(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<CodeLight content={`https://wr.do/api/scraping/text`} />
|
||||
<Card className="bg-gray-50 dark:bg-gray-900">
|
||||
<CardHeader>
|
||||
<CardTitle>Text</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center">
|
||||
<Select
|
||||
onValueChange={(value: string) => {
|
||||
setProtocol(value);
|
||||
}}
|
||||
name="protocol"
|
||||
defaultValue={"https://"}
|
||||
>
|
||||
<SelectTrigger className="h-10 w-24 rounded-r-none bg-transparent shadow-inner">
|
||||
<SelectValue placeholder="Protocol" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem key="https" value="https://">
|
||||
https://
|
||||
</SelectItem>
|
||||
<SelectItem key="http" value="http://">
|
||||
http://
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="www.example.com"
|
||||
className="h-10 rounded-none border focus:border-primary active:border-primary"
|
||||
value={currentLink}
|
||||
size={100}
|
||||
onChange={(e) => setCurrentLink(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
variant="blue"
|
||||
onClick={handleScrapingMeta}
|
||||
disabled={isScraping}
|
||||
className="rounded-l-none"
|
||||
>
|
||||
{isScraping ? "Scraping..." : "Send"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 rounded-md border p-3">
|
||||
<JsonView
|
||||
style={theme === "dark" ? vscodeTheme : githubLightTheme}
|
||||
value={metaInfo}
|
||||
displayObjectSize={false}
|
||||
displayDataTypes={false}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export const CodeLight = ({ content }: { content: string }) => {
|
||||
const code = content.trim();
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full">
|
||||
<pre className="overflow-x-auto rounded-lg bg-gray-900 p-4 text-gray-100">
|
||||
<code className="block font-mono text-sm">
|
||||
{code.split("\n").map((line, i) => (
|
||||
<div key={i} className="group relative">
|
||||
{/* Line number */}
|
||||
<span className="inline-block w-8 select-none text-gray-500">
|
||||
{i + 1}
|
||||
</span>
|
||||
{/* Code content */}
|
||||
<span className="text-green-400">
|
||||
{line
|
||||
.replace(
|
||||
/function/,
|
||||
(match) => `<span class="text-purple-400">${match}</span>`,
|
||||
)
|
||||
.replace(
|
||||
/"[^"]*"/,
|
||||
(match) => `<span class="text-green-400">${match}</span>`,
|
||||
)
|
||||
.replace(
|
||||
/console/,
|
||||
(match) => `<span class="text-yellow-400">${match}</span>`,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</code>
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { DashboardHeader } from "@/components/dashboard/header";
|
||||
|
||||
export default function DashboardRecordsLoading() {
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader heading="Scraping API" text="" />
|
||||
<Skeleton className="h-full w-full rounded-lg" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
import { constructMetadata } from "@/lib/utils";
|
||||
import { DashboardHeader } from "@/components/dashboard/header";
|
||||
|
||||
import DashboardScrapeCharts from "../charts";
|
||||
import { ScreenshotScraping } from "../scrapes";
|
||||
|
||||
export const metadata = constructMetadata({
|
||||
title: "Url to Screenshot API - WR.DO",
|
||||
description:
|
||||
"Quickly extract website screenshots. It's free and unlimited to use!",
|
||||
});
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const user = await getCurrentUser();
|
||||
|
||||
if (!user?.id) redirect("/login");
|
||||
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader
|
||||
heading="Url to Screenshot API"
|
||||
text="Quickly extract website screenshots. It's free and unlimited to use!"
|
||||
link="/docs/open-api/screenshot"
|
||||
linkText="Screenshot API."
|
||||
/>
|
||||
<ScreenshotScraping user={{ id: user.id, apiKey: user.apiKey }} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import * as React from "react";
|
||||
import Link from "next/link";
|
||||
import { UrlMeta } from "@prisma/client";
|
||||
import { VisSingleContainer, VisTooltip, VisTopoJSONMap } from "@unovis/react";
|
||||
import { Donut, MapData, TopoJSONMap } from "@unovis/ts";
|
||||
import { TopoJSONMap } from "@unovis/ts";
|
||||
import { WorldMapTopoJSON } from "@unovis/ts/maps";
|
||||
import { Area, AreaChart, Bar, BarChart, CartesianGrid, XAxis } from "recharts";
|
||||
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
// app/api/logs/route.ts
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { getScrapeStatsByUserId } from "@/lib/dto/scrape";
|
||||
|
||||
export interface LogsResponse {
|
||||
logs: {
|
||||
id: string;
|
||||
type: string;
|
||||
ip: string;
|
||||
link: string;
|
||||
createdAt: Date;
|
||||
}[];
|
||||
total: number;
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
export interface LogsQueryParams {
|
||||
userId: string;
|
||||
page?: number;
|
||||
type?: string;
|
||||
ip?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
const rateLimit = new Map<string, { count: number; timestamp: number }>();
|
||||
|
||||
function checkRateLimit(ip: string): boolean {
|
||||
const now = Date.now();
|
||||
const windowMs = 60 * 1000; // 1分钟窗口
|
||||
const maxRequests = 60; // 每分钟最大请求数
|
||||
|
||||
const current = rateLimit.get(ip) || { count: 0, timestamp: now };
|
||||
|
||||
// 重置过期的窗口
|
||||
if (now - current.timestamp > windowMs) {
|
||||
current.count = 0;
|
||||
current.timestamp = now;
|
||||
}
|
||||
|
||||
// 增加计数
|
||||
current.count++;
|
||||
rateLimit.set(ip, current);
|
||||
|
||||
return current.count <= maxRequests;
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const ip =
|
||||
request.ip || request.headers.get("x-forwarded-for") || "127.0.0.1";
|
||||
|
||||
// 检查速率限制
|
||||
if (!checkRateLimit(ip)) {
|
||||
return Response.json(
|
||||
{ error: "Too many requests" },
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
"Retry-After": "60",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const queryParams: LogsQueryParams = {
|
||||
userId: searchParams.get("userId") || "",
|
||||
page: searchParams.get("page") ? parseInt(searchParams.get("page")!) : 1,
|
||||
type: searchParams.get("type") || undefined,
|
||||
ip: searchParams.get("ip") || undefined,
|
||||
};
|
||||
|
||||
// 参数验证
|
||||
if (!queryParams.userId) {
|
||||
return Response.json({ error: "userId is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (queryParams.page && (isNaN(queryParams.page) || queryParams.page < 1)) {
|
||||
return Response.json({ error: "Invalid page number" }, { status: 400 });
|
||||
}
|
||||
|
||||
const data = await getScrapeStatsByUserId(queryParams);
|
||||
|
||||
// 构造响应
|
||||
const response: LogsResponse = {
|
||||
logs: data.logs,
|
||||
total: data.total,
|
||||
hasMore: data.hasMore,
|
||||
};
|
||||
|
||||
return new Response(JSON.stringify(response), {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Cache-Control": "no-store",
|
||||
// CORS 头部
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type, Authorization",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error fetching logs:", error);
|
||||
|
||||
return Response.json(
|
||||
{
|
||||
error: "Internal server error",
|
||||
message:
|
||||
process.env.NODE_ENV === "development"
|
||||
? (error as Error).message
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
status: 500,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// OPTIONS 请求处理,用于CORS预检
|
||||
export async function OPTIONS(request: NextRequest) {
|
||||
return new Response(null, {
|
||||
headers: {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type, Authorization",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -73,3 +73,37 @@ export function HeroCard() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export async function ScrapeInfoCard({
|
||||
userId,
|
||||
title,
|
||||
desc,
|
||||
link,
|
||||
icon = "users",
|
||||
}: {
|
||||
userId: string;
|
||||
title: string;
|
||||
desc?: string;
|
||||
link: string;
|
||||
icon?: keyof typeof Icons;
|
||||
}) {
|
||||
const Icon = Icons[icon || "arrowRight"];
|
||||
return (
|
||||
<Card className="grids group bg-gray-50/70 backdrop-blur-lg dark:bg-primary-foreground">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
<Link
|
||||
className="font-semibold text-slate-500 duration-500 group-hover:text-blue-500 group-hover:underline"
|
||||
href={link}
|
||||
>
|
||||
{title}
|
||||
</Link>
|
||||
</CardTitle>
|
||||
<Icon className="size-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-xs text-muted-foreground">{desc}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
BadgeHelp,
|
||||
BookOpen,
|
||||
Bug,
|
||||
Camera,
|
||||
Check,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
Flame,
|
||||
Globe,
|
||||
GlobeLock,
|
||||
Heading1,
|
||||
HelpCircle,
|
||||
Home,
|
||||
Image,
|
||||
@@ -31,6 +33,7 @@ import {
|
||||
MoreVertical,
|
||||
Package,
|
||||
Plus,
|
||||
QrCode,
|
||||
Search,
|
||||
Settings,
|
||||
SunMedium,
|
||||
@@ -54,6 +57,9 @@ export const Icons = {
|
||||
check: Check,
|
||||
close: X,
|
||||
copy: Copy,
|
||||
camera: Camera,
|
||||
fileText: FileText,
|
||||
|
||||
dashboard: LayoutPanelLeft,
|
||||
ellipsis: MoreVertical,
|
||||
github: ({ ...props }: LucideProps) => (
|
||||
@@ -92,6 +98,8 @@ export const Icons = {
|
||||
),
|
||||
help: HelpCircle,
|
||||
home: Home,
|
||||
heading1: Heading1,
|
||||
qrcode: QrCode,
|
||||
laptop: Laptop,
|
||||
lineChart: LineChart,
|
||||
logo: LogoIcon,
|
||||
|
||||
+30
-1
@@ -11,7 +11,36 @@ export const sidebarLinks: SidebarNavItem[] = [
|
||||
{ href: "/dashboard", icon: "dashboard", title: "Dashboard" },
|
||||
{ href: "/dashboard/records", icon: "globeLock", title: "DNS Records" },
|
||||
{ href: "/dashboard/urls", icon: "link", title: "Short Urls" },
|
||||
{ href: "/dashboard/scrape", icon: "bug", title: "Scraping API" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "SCRAPE",
|
||||
items: [
|
||||
{
|
||||
href: "/dashboard/scrape",
|
||||
icon: "bug",
|
||||
title: "Scraping API",
|
||||
},
|
||||
{
|
||||
href: "/dashboard/scrape/screenshot",
|
||||
icon: "camera",
|
||||
title: "Screenshot",
|
||||
},
|
||||
{
|
||||
href: "/dashboard/scrape/meta-info",
|
||||
icon: "globe",
|
||||
title: "Meta Info",
|
||||
},
|
||||
{
|
||||
href: "/dashboard/scrape/markdown",
|
||||
icon: "fileText",
|
||||
title: "Markdown & Text",
|
||||
},
|
||||
{
|
||||
href: "/dashboard/scrape/qrcode",
|
||||
icon: "qrcode",
|
||||
title: "QR Code",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -57,3 +57,70 @@ export async function getScrapeStatsByType(type: string) {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function getScrapeStatsByTypeAndUserId(type: string, id: string) {
|
||||
return await prisma.scrapeMeta.findMany({
|
||||
where: {
|
||||
type,
|
||||
userId: id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function getScrapeStatsByUserId({
|
||||
userId,
|
||||
page = 1,
|
||||
limit = 20,
|
||||
type,
|
||||
ip,
|
||||
}: {
|
||||
userId: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
type?: string;
|
||||
ip?: string;
|
||||
}) {
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const where = {
|
||||
userId,
|
||||
...(type && { type }),
|
||||
...(ip && { ip }),
|
||||
};
|
||||
|
||||
const [total, logs] = await Promise.all([
|
||||
prisma.scrapeMeta.count({ where }),
|
||||
prisma.scrapeMeta.findMany({
|
||||
where,
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
skip,
|
||||
take: limit,
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
ip: true,
|
||||
link: true,
|
||||
createdAt: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
logs,
|
||||
total,
|
||||
hasMore: total > skip + logs.length,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getScrapeStatsByUserId1(userId: string) {
|
||||
return await prisma.scrapeMeta.findMany({
|
||||
where: {
|
||||
userId,
|
||||
},
|
||||
orderBy: {
|
||||
updatedAt: "asc",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user