[dyad] Added link analysis feature - wrote 4 file(s)
This commit is contained in:
@@ -21,6 +21,13 @@ export interface ImageAltData {
|
|||||||
size: number | null;
|
size: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LinkData {
|
||||||
|
href: string;
|
||||||
|
text: string;
|
||||||
|
type: "internal" | "external" | "anchor" | "other";
|
||||||
|
rel: string;
|
||||||
|
}
|
||||||
|
|
||||||
export async function extractMetaData(url: string, keyword?: string) {
|
export async function extractMetaData(url: string, keyword?: string) {
|
||||||
if (!url) {
|
if (!url) {
|
||||||
return { error: "URL is required." };
|
return { error: "URL is required." };
|
||||||
@@ -175,7 +182,6 @@ export async function extractMetaData(url: string, keyword?: string) {
|
|||||||
|
|
||||||
const imageSizePromises = imageSrcs.map(async (img) => {
|
const imageSizePromises = imageSrcs.map(async (img) => {
|
||||||
try {
|
try {
|
||||||
// Use a HEAD request for efficiency
|
|
||||||
const res = await fetch(img.src, { method: "HEAD" });
|
const res = await fetch(img.src, { method: "HEAD" });
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const contentLength = res.headers.get("content-length");
|
const contentLength = res.headers.get("content-length");
|
||||||
@@ -192,6 +198,33 @@ export async function extractMetaData(url: string, keyword?: string) {
|
|||||||
|
|
||||||
const imageAltData: ImageAltData[] = await Promise.all(imageSizePromises);
|
const imageAltData: ImageAltData[] = await Promise.all(imageSizePromises);
|
||||||
|
|
||||||
|
const links: LinkData[] = [];
|
||||||
|
const pageUrl = new URL(formattedUrl);
|
||||||
|
|
||||||
|
$("a").each((i, el) => {
|
||||||
|
const href = $(el).attr("href");
|
||||||
|
if (!href) return;
|
||||||
|
|
||||||
|
const text = $(el).text().trim();
|
||||||
|
const rel = $(el).attr("rel") || "";
|
||||||
|
let type: LinkData["type"] = "external";
|
||||||
|
let absoluteUrl = href;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const linkUrl = new URL(href, formattedUrl);
|
||||||
|
absoluteUrl = linkUrl.href;
|
||||||
|
if (linkUrl.hostname === pageUrl.hostname) {
|
||||||
|
type = "internal";
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (href.startsWith("#")) type = "anchor";
|
||||||
|
else if (href.startsWith("mailto:") || href.startsWith("tel:"))
|
||||||
|
type = "other";
|
||||||
|
}
|
||||||
|
|
||||||
|
links.push({ href: absoluteUrl, text, type, rel });
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data: {
|
data: {
|
||||||
title,
|
title,
|
||||||
@@ -207,6 +240,7 @@ export async function extractMetaData(url: string, keyword?: string) {
|
|||||||
keyword: trimmedKeyword || null,
|
keyword: trimmedKeyword || null,
|
||||||
keywordCount,
|
keywordCount,
|
||||||
images: imageAltData.length > 0 ? imageAltData : null,
|
images: imageAltData.length > 0 ? imageAltData : null,
|
||||||
|
links: links.length > 0 ? links : null,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
134
src/components/links-display.tsx
Normal file
134
src/components/links-display.tsx
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useMemo } from "react";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { ExternalLink, Link as LinkIcon } from "lucide-react";
|
||||||
|
import type { LinkData } from "@/app/actions";
|
||||||
|
|
||||||
|
interface LinksDisplayProps {
|
||||||
|
links: LinkData[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const StatCard = ({ title, value }: { title: string; value: number }) => (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">{title}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{value}</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
|
||||||
|
export function LinksDisplay({ links }: LinksDisplayProps) {
|
||||||
|
const [filter, setFilter] = useState<"all" | "internal" | "external">("all");
|
||||||
|
|
||||||
|
const stats = useMemo(() => {
|
||||||
|
return {
|
||||||
|
total: links.length,
|
||||||
|
internal: links.filter((l) => l.type === "internal").length,
|
||||||
|
external: links.filter((l) => l.type === "external").length,
|
||||||
|
nofollow: links.filter((l) => l.rel.includes("nofollow")).length,
|
||||||
|
};
|
||||||
|
}, [links]);
|
||||||
|
|
||||||
|
const filteredLinks = useMemo(() => {
|
||||||
|
if (filter === "all") return links;
|
||||||
|
return links.filter((l) => l.type === filter);
|
||||||
|
}, [links, filter]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<StatCard title="Total Links" value={stats.total} />
|
||||||
|
<StatCard title="Internal Links" value={stats.internal} />
|
||||||
|
<StatCard title="External Links" value={stats.external} />
|
||||||
|
<StatCard title="Nofollow Links" value={stats.nofollow} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant={filter === "all" ? "default" : "outline"}
|
||||||
|
onClick={() => setFilter("all")}
|
||||||
|
>
|
||||||
|
All
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={filter === "internal" ? "default" : "outline"}
|
||||||
|
onClick={() => setFilter("internal")}
|
||||||
|
>
|
||||||
|
Internal
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={filter === "external" ? "default" : "outline"}
|
||||||
|
onClick={() => setFilter("external")}
|
||||||
|
>
|
||||||
|
External
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>URL</TableHead>
|
||||||
|
<TableHead>Anchor Text</TableHead>
|
||||||
|
<TableHead className="text-center">Type</TableHead>
|
||||||
|
<TableHead className="text-center">Attributes</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{filteredLinks.map((link, index) => (
|
||||||
|
<TableRow key={index}>
|
||||||
|
<TableCell className="max-w-xs truncate">
|
||||||
|
<a
|
||||||
|
href={link.href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center gap-2 hover:underline"
|
||||||
|
>
|
||||||
|
{link.type === "external" ? (
|
||||||
|
<ExternalLink className="h-4 w-4 flex-shrink-0" />
|
||||||
|
) : (
|
||||||
|
<LinkIcon className="h-4 w-4 flex-shrink-0" />
|
||||||
|
)}
|
||||||
|
<span className="truncate">{link.href}</span>
|
||||||
|
</a>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{link.text || "No anchor text"}</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<Badge variant="secondary" className="capitalize">
|
||||||
|
{link.type}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
{link.rel ? (
|
||||||
|
<div className="flex justify-center gap-1">
|
||||||
|
{link.rel.split(" ").map((r) => (
|
||||||
|
<Badge key={r} variant="outline">
|
||||||
|
{r}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
"-"
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import { SchemaDisplay } from "./schema-display";
|
|||||||
import { MetaFormInputs } from "./meta-form-inputs";
|
import { MetaFormInputs } from "./meta-form-inputs";
|
||||||
import { AnalysisTab } from "./analysis-tab";
|
import { AnalysisTab } from "./analysis-tab";
|
||||||
import { SocialTab } from "./social-tab";
|
import { SocialTab } from "./social-tab";
|
||||||
|
import { LinksDisplay } from "./links-display";
|
||||||
import type { MetaData } from "@/lib/types";
|
import type { MetaData } from "@/lib/types";
|
||||||
|
|
||||||
export function MetaForm() {
|
export function MetaForm() {
|
||||||
@@ -108,6 +109,10 @@ export function MetaForm() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Links Tab
|
||||||
|
const linksColor: IndicatorColor =
|
||||||
|
metaData.links && metaData.links.length > 0 ? "green" : "gray";
|
||||||
|
|
||||||
// FAQ Tab
|
// FAQ Tab
|
||||||
let faqColor: IndicatorColor = "gray";
|
let faqColor: IndicatorColor = "gray";
|
||||||
if (metaData.faq && metaData.faq.length > 0) {
|
if (metaData.faq && metaData.faq.length > 0) {
|
||||||
@@ -125,6 +130,7 @@ export function MetaForm() {
|
|||||||
headlines: headlinesColor,
|
headlines: headlinesColor,
|
||||||
images: imagesColor,
|
images: imagesColor,
|
||||||
social: socialColor,
|
social: socialColor,
|
||||||
|
links: linksColor,
|
||||||
faq: faqColor,
|
faq: faqColor,
|
||||||
schema: schemaColor,
|
schema: schemaColor,
|
||||||
};
|
};
|
||||||
@@ -200,6 +206,12 @@ export function MetaForm() {
|
|||||||
Images
|
Images
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
)}
|
)}
|
||||||
|
{metaData.links && metaData.links.length > 0 && (
|
||||||
|
<TabsTrigger value="links">
|
||||||
|
{tabColors && <TabIndicator color={tabColors.links} />}
|
||||||
|
Links
|
||||||
|
</TabsTrigger>
|
||||||
|
)}
|
||||||
{metaData.openGraph && metaData.twitter && (
|
{metaData.openGraph && metaData.twitter && (
|
||||||
<TabsTrigger value="social">
|
<TabsTrigger value="social">
|
||||||
{tabColors && <TabIndicator color={tabColors.social} />}
|
{tabColors && <TabIndicator color={tabColors.social} />}
|
||||||
@@ -263,6 +275,16 @@ export function MetaForm() {
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{metaData.links && metaData.links.length > 0 && (
|
||||||
|
<TabsContent value="links">
|
||||||
|
<Card className="w-full shadow-lg rounded-lg">
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<LinksDisplay links={metaData.links} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
)}
|
||||||
|
|
||||||
{metaData.openGraph && metaData.twitter && (
|
{metaData.openGraph && metaData.twitter && (
|
||||||
<TabsContent value="social">
|
<TabsContent value="social">
|
||||||
<SocialTab
|
<SocialTab
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { HeadlineNode, ImageAltData } from "@/app/actions";
|
import type { HeadlineNode, ImageAltData, LinkData } from "@/app/actions";
|
||||||
|
|
||||||
export interface OpenGraphData {
|
export interface OpenGraphData {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -32,4 +32,5 @@ export interface MetaData {
|
|||||||
robots?: string | null;
|
robots?: string | null;
|
||||||
openGraph?: OpenGraphData;
|
openGraph?: OpenGraphData;
|
||||||
twitter?: TwitterData;
|
twitter?: TwitterData;
|
||||||
|
links?: LinkData[] | null;
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user