[dyad] Added link analysis feature - wrote 4 file(s)

This commit is contained in:
[dyad]
2026-01-20 16:21:07 +01:00
parent e5d6580987
commit a04c53bae4
4 changed files with 193 additions and 2 deletions

View File

@@ -21,6 +21,13 @@ export interface ImageAltData {
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) {
if (!url) {
return { error: "URL is required." };
@@ -175,7 +182,6 @@ export async function extractMetaData(url: string, keyword?: string) {
const imageSizePromises = imageSrcs.map(async (img) => {
try {
// Use a HEAD request for efficiency
const res = await fetch(img.src, { method: "HEAD" });
if (res.ok) {
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 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 {
data: {
title,
@@ -207,6 +240,7 @@ export async function extractMetaData(url: string, keyword?: string) {
keyword: trimmedKeyword || null,
keywordCount,
images: imageAltData.length > 0 ? imageAltData : null,
links: links.length > 0 ? links : null,
},
};
} catch (error) {

View 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>
);
}

View File

@@ -14,6 +14,7 @@ import { SchemaDisplay } from "./schema-display";
import { MetaFormInputs } from "./meta-form-inputs";
import { AnalysisTab } from "./analysis-tab";
import { SocialTab } from "./social-tab";
import { LinksDisplay } from "./links-display";
import type { MetaData } from "@/lib/types";
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
let faqColor: IndicatorColor = "gray";
if (metaData.faq && metaData.faq.length > 0) {
@@ -125,6 +130,7 @@ export function MetaForm() {
headlines: headlinesColor,
images: imagesColor,
social: socialColor,
links: linksColor,
faq: faqColor,
schema: schemaColor,
};
@@ -200,6 +206,12 @@ export function MetaForm() {
Images
</TabsTrigger>
)}
{metaData.links && metaData.links.length > 0 && (
<TabsTrigger value="links">
{tabColors && <TabIndicator color={tabColors.links} />}
Links
</TabsTrigger>
)}
{metaData.openGraph && metaData.twitter && (
<TabsTrigger value="social">
{tabColors && <TabIndicator color={tabColors.social} />}
@@ -263,6 +275,16 @@ export function MetaForm() {
</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 && (
<TabsContent value="social">
<SocialTab

View File

@@ -1,4 +1,4 @@
import type { HeadlineNode, ImageAltData } from "@/app/actions";
import type { HeadlineNode, ImageAltData, LinkData } from "@/app/actions";
export interface OpenGraphData {
title: string;
@@ -32,4 +32,5 @@ export interface MetaData {
robots?: string | null;
openGraph?: OpenGraphData;
twitter?: TwitterData;
links?: LinkData[] | null;
}