From 868d83fb1c70d17201df19493334e5f0ad7340ab Mon Sep 17 00:00:00 2001 From: Ben Senescu <44480372+bensenescu@users.noreply.github.com> Date: Tue, 7 Apr 2026 21:53:08 -0400 Subject: [PATCH] Add per-tab CSV export for backlinks results (#95) * Add CSV export per backlinks tab * save --- .../backlinks/BacklinksPageContent.tsx | 1 + .../backlinks/BacklinksPageSections.tsx | 143 +--------------- .../backlinks/BacklinksResultsHeader.tsx | 162 ++++++++++++++++++ src/client/features/backlinks/export.test.ts | 93 ++++++++++ src/client/features/backlinks/export.ts | 132 ++++++++++++++ 5 files changed, 394 insertions(+), 137 deletions(-) create mode 100644 src/client/features/backlinks/BacklinksResultsHeader.tsx create mode 100644 src/client/features/backlinks/export.test.ts create mode 100644 src/client/features/backlinks/export.ts diff --git a/src/client/features/backlinks/BacklinksPageContent.tsx b/src/client/features/backlinks/BacklinksPageContent.tsx index 4229772..7385640 100644 --- a/src/client/features/backlinks/BacklinksPageContent.tsx +++ b/src/client/features/backlinks/BacklinksPageContent.tsx @@ -155,6 +155,7 @@ export function BacklinksBody({ onSetActiveTab={onSetActiveTab} onSetHideSpam={onSetHideSpam} onSetSpamThreshold={onSetSpamThreshold} + exportTarget={mergedData.displayTarget || searchState.target} /> ); diff --git a/src/client/features/backlinks/BacklinksPageSections.tsx b/src/client/features/backlinks/BacklinksPageSections.tsx index 6d39827..98d153d 100644 --- a/src/client/features/backlinks/BacklinksPageSections.tsx +++ b/src/client/features/backlinks/BacklinksPageSections.tsx @@ -1,10 +1,9 @@ -import { useEffect, useState } from "react"; import { HeaderHelpLabel } from "@/client/features/keywords/components"; -import { Search } from "lucide-react"; import { BacklinksNewLostChart, BacklinksTrendChart, } from "./BacklinksPageCharts"; +import { ResultsHeader } from "./BacklinksResultsHeader"; import { BacklinksTable } from "./BacklinksTable"; import { ReferringDomainsTable } from "./ReferringDomainsTable"; import { TopPagesTable } from "./TopPagesTable"; @@ -12,10 +11,7 @@ import type { BacklinksOverviewData, BacklinksSearchState, } from "./backlinksPageTypes"; -import { - TAB_DESCRIPTIONS, - formatRelativeTimestamp, -} from "./backlinksPageUtils"; +import { formatRelativeTimestamp } from "./backlinksPageUtils"; export function BacklinksOverviewPanels({ data, @@ -58,6 +54,7 @@ export function BacklinksResultsCard({ onSetActiveTab, onSetHideSpam, onSetSpamThreshold, + exportTarget, }: { activeTab: BacklinksSearchState["tab"]; filteredData: { @@ -74,6 +71,7 @@ export function BacklinksResultsCard({ onSetActiveTab: (tab: BacklinksSearchState["tab"]) => void; onSetHideSpam: (hideSpam: boolean) => void; onSetSpamThreshold: (threshold: number) => void; + exportTarget: string; }) { return (
@@ -87,6 +85,8 @@ export function BacklinksResultsCard({ onSetActiveTab={onSetActiveTab} onSetHideSpam={onSetHideSpam} onSetSpamThreshold={onSetSpamThreshold} + filteredData={filteredData} + exportTarget={exportTarget} /> {tabErrorMessage ? (
@@ -132,115 +132,6 @@ function OverviewGrid({ ); } -function ResultsHeader({ - activeTab, - filterText, - hideSpam, - spamThreshold, - onFilterTextChange, - onSetActiveTab, - onSetHideSpam, - onSetSpamThreshold, -}: { - activeTab: BacklinksSearchState["tab"]; - filterText: string; - hideSpam: boolean; - spamThreshold: number; - onFilterTextChange: (value: string) => void; - onSetActiveTab: (tab: BacklinksSearchState["tab"]) => void; - onSetHideSpam: (hideSpam: boolean) => void; - onSetSpamThreshold: (threshold: number) => void; -}) { - const [draftSpamThreshold, setDraftSpamThreshold] = useState( - String(spamThreshold), - ); - - useEffect(() => { - setDraftSpamThreshold(String(spamThreshold)); - }, [spamThreshold]); - - function commitSpamThreshold() { - onSetSpamThreshold( - draftSpamThreshold.trim() === "" - ? spamThreshold - : Number(draftSpamThreshold), - ); - } - - return ( -
-
-
- - Backlinks - - - Referring Domains - - - Top Pages - -
-

- {TAB_DESCRIPTIONS[activeTab]} -

-
- -
- - {activeTab === "backlinks" ? ( -
- - -
- ) : null} -
-
- ); -} - function SummaryStatsGrid({ data, summaryStats, @@ -312,28 +203,6 @@ function TrendCard({ ); } -function TabButton({ - activeTab, - children, - onClick, - tab, -}: { - activeTab: BacklinksSearchState["tab"]; - children: string; - onClick: (tab: BacklinksSearchState["tab"]) => void; - tab: BacklinksSearchState["tab"]; -}) { - return ( - - ); -} - function TabLoadingState({ label }: { label: string }) { return (
diff --git a/src/client/features/backlinks/BacklinksResultsHeader.tsx b/src/client/features/backlinks/BacklinksResultsHeader.tsx new file mode 100644 index 0000000..ce0b7e5 --- /dev/null +++ b/src/client/features/backlinks/BacklinksResultsHeader.tsx @@ -0,0 +1,162 @@ +import { useEffect, useState } from "react"; +import { Download, Search } from "lucide-react"; +import type { + BacklinksOverviewData, + BacklinksSearchState, +} from "./backlinksPageTypes"; +import { TAB_DESCRIPTIONS } from "./backlinksPageUtils"; +import { exportBacklinksTabCsv } from "./export"; + +type BacklinksResultsData = { + backlinks: BacklinksOverviewData["backlinks"]; + referringDomains: BacklinksOverviewData["referringDomains"]; + topPages: BacklinksOverviewData["topPages"]; +}; + +export function ResultsHeader({ + activeTab, + filterText, + hideSpam, + spamThreshold, + onFilterTextChange, + onSetActiveTab, + onSetHideSpam, + onSetSpamThreshold, + filteredData, + exportTarget, +}: { + activeTab: BacklinksSearchState["tab"]; + filterText: string; + hideSpam: boolean; + spamThreshold: number; + onFilterTextChange: (value: string) => void; + onSetActiveTab: (tab: BacklinksSearchState["tab"]) => void; + onSetHideSpam: (hideSpam: boolean) => void; + onSetSpamThreshold: (threshold: number) => void; + filteredData: BacklinksResultsData; + exportTarget: string; +}) { + const [draftSpamThreshold, setDraftSpamThreshold] = useState( + String(spamThreshold), + ); + + useEffect(() => { + setDraftSpamThreshold(String(spamThreshold)); + }, [spamThreshold]); + + function commitSpamThreshold() { + onSetSpamThreshold( + draftSpamThreshold.trim() === "" + ? spamThreshold + : Number(draftSpamThreshold), + ); + } + + return ( +
+
+
+ + Backlinks + + + Referring Domains + + + Top Pages + +
+

+ {TAB_DESCRIPTIONS[activeTab]} +

+
+ +
+ + + {activeTab === "backlinks" ? ( +
+ + +
+ ) : null} +
+
+ ); +} + +function TabButton({ + activeTab, + children, + onClick, + tab, +}: { + activeTab: BacklinksSearchState["tab"]; + children: string; + onClick: (tab: BacklinksSearchState["tab"]) => void; + tab: BacklinksSearchState["tab"]; +}) { + return ( + + ); +} diff --git a/src/client/features/backlinks/export.test.ts b/src/client/features/backlinks/export.test.ts new file mode 100644 index 0000000..74e3711 --- /dev/null +++ b/src/client/features/backlinks/export.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vitest"; +import { buildBacklinksTabCsvFile } from "./export"; + +describe("buildBacklinksTabCsvFile", () => { + it("builds backlinks csv with backlink-specific columns", () => { + const file = buildBacklinksTabCsvFile({ + tab: "backlinks", + target: "https://Example.com/path?q=1", + rows: { + backlinks: [ + { + domainFrom: "example.org", + urlFrom: "https://example.org/post", + urlTo: "https://example.com/path", + anchor: "Example", + itemType: "organic", + isDofollow: true, + relAttributes: ["noopener", "noreferrer"], + rank: 123, + domainFromRank: 45, + pageFromRank: 12, + spamScore: 10, + firstSeen: "2025-01-01", + lastSeen: "2025-01-15", + isLost: false, + isBroken: false, + linksCount: 2, + }, + ], + referringDomains: [], + topPages: [], + }, + }); + + expect(file.filename).toBe("backlinks-backlinks-example.com-path-q-1.csv"); + expect(file.content).toContain('"Domain","Source URL","Target URL"'); + expect(file.content).toContain('"example.org"'); + expect(file.content).toContain('"noopener, noreferrer"'); + }); + + it("builds referring domains csv", () => { + const file = buildBacklinksTabCsvFile({ + tab: "domains", + target: "Example.com", + rows: { + backlinks: [], + referringDomains: [ + { + domain: "source.com", + backlinks: 12, + referringPages: 7, + rank: 101, + spamScore: 4, + firstSeen: "2024-05-10", + brokenBacklinks: 1, + brokenPages: 0, + }, + ], + topPages: [], + }, + }); + + expect(file.filename).toBe("backlinks-referring-domains-example.com.csv"); + expect(file.content).toContain('"Domain","Backlinks","Referring Pages"'); + expect(file.content).toContain('"source.com"'); + }); + + it("builds top pages csv", () => { + const file = buildBacklinksTabCsvFile({ + tab: "pages", + target: "docs.example.com", + rows: { + backlinks: [], + referringDomains: [], + topPages: [ + { + page: "https://docs.example.com/start", + backlinks: 22, + referringDomains: 9, + rank: 88, + brokenBacklinks: 0, + }, + ], + }, + }); + + expect(file.filename).toBe("backlinks-top-pages-docs.example.com.csv"); + expect(file.content).toContain( + '"Page","Backlinks","Referring Domains","Rank","Broken Backlinks"', + ); + expect(file.content).toContain('"https://docs.example.com/start"'); + }); +}); diff --git a/src/client/features/backlinks/export.ts b/src/client/features/backlinks/export.ts new file mode 100644 index 0000000..279e3e0 --- /dev/null +++ b/src/client/features/backlinks/export.ts @@ -0,0 +1,132 @@ +import { buildCsv, downloadCsv } from "@/client/lib/csv"; +import type { + BacklinksOverviewData, + BacklinksSearchState, +} from "./backlinksPageTypes"; + +type BacklinksFilteredData = { + backlinks: BacklinksOverviewData["backlinks"]; + referringDomains: BacklinksOverviewData["referringDomains"]; + topPages: BacklinksOverviewData["topPages"]; +}; + +export function buildBacklinksTabCsvFile(args: { + tab: BacklinksSearchState["tab"]; + target: string; + rows: BacklinksFilteredData; +}) { + const { tab, target, rows } = args; + + if (tab === "backlinks") { + const headers = [ + "Domain", + "Source URL", + "Target URL", + "Anchor", + "Type", + "Dofollow", + "Rel Attributes", + "Domain Rank", + "Source Page Rank", + "Target Rank", + "Spam Score", + "First Seen", + "Last Seen", + "Lost", + "Broken", + "Links Count", + ]; + const lines = rows.backlinks.map((row) => [ + row.domainFrom, + row.urlFrom, + row.urlTo, + row.anchor, + row.itemType, + row.isDofollow, + row.relAttributes.join(", "), + row.domainFromRank, + row.pageFromRank, + row.rank, + row.spamScore, + row.firstSeen, + row.lastSeen, + row.isLost, + row.isBroken, + row.linksCount, + ]); + + return { + filename: buildFilename("backlinks", target), + content: buildCsv(headers, lines), + }; + } + + if (tab === "domains") { + const headers = [ + "Domain", + "Backlinks", + "Referring Pages", + "Rank", + "Spam Score", + "First Seen", + "Broken Backlinks", + "Broken Pages", + ]; + const lines = rows.referringDomains.map((row) => [ + row.domain, + row.backlinks, + row.referringPages, + row.rank, + row.spamScore, + row.firstSeen, + row.brokenBacklinks, + row.brokenPages, + ]); + + return { + filename: buildFilename("referring-domains", target), + content: buildCsv(headers, lines), + }; + } + + const headers = [ + "Page", + "Backlinks", + "Referring Domains", + "Rank", + "Broken Backlinks", + ]; + const lines = rows.topPages.map((row) => [ + row.page, + row.backlinks, + row.referringDomains, + row.rank, + row.brokenBacklinks, + ]); + + return { + filename: buildFilename("top-pages", target), + content: buildCsv(headers, lines), + }; +} + +export function exportBacklinksTabCsv(args: { + tab: BacklinksSearchState["tab"]; + target: string; + rows: BacklinksFilteredData; +}) { + const file = buildBacklinksTabCsvFile(args); + downloadCsv(file.filename, file.content); +} + +function buildFilename(tabPrefix: string, target: string) { + const normalizedTarget = target + .toLowerCase() + .trim() + .replace(/https?:\/\//g, "") + .replace(/[^a-z0-9.-]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 80); + + return `backlinks-${tabPrefix}${normalizedTarget ? `-${normalizedTarget}` : ""}.csv`; +}