This commit is contained in:
宋俊熙
2024-11-14 16:39:33 +08:00
parent 4e5efdc8ba
commit 619ac7cabb
22 changed files with 1451 additions and 250 deletions
+13 -7
View File
@@ -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>
);
}
+1 -1
View File
@@ -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" />
</>
);
+181
View File
@@ -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&nbsp;&nbsp;to&nbsp;&nbsp;Markdown&nbsp;&nbsp;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&nbsp;&nbsp;to&nbsp;&nbsp;Meta&nbsp;&nbsp;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>
</>
);
}
+42 -2
View File
@@ -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&nbsp;&nbsp;to&nbsp;&nbsp;Screenshot&nbsp;&nbsp;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";
+133
View File
@@ -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>
);
}
+8
View File
@@ -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
View File
@@ -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",
},
],
},
{
+67
View File
@@ -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",
},
});
}