Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: CRAN download statistics #194

Merged
merged 3 commits into from
Oct 30, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,16 @@ import { format, sub } from "date-fns";
import {
CranDownloadsResponse,
CranResponse,
CranTopDownloadedPackagesRes,
CranTrendingPackagesRes,
PackageDownloadTrend,
} from "./types";
import { TopDownloadedPackagesRange } from "./package-insight.shape";
import { slog } from "../modules/observability.server";

export class PackageInsightService {
private static readonly CRAN_LOGS_URL = "https://cranlogs.r-pkg.org";

/**
* Get the downloads for a package in the last n days, starting
* from today. The result is an array of objects with the number
Expand Down Expand Up @@ -80,6 +86,24 @@ export class PackageInsightService {
return downloads;
}

static async getTopDownloadedPackages(
period: TopDownloadedPackagesRange,
count: number,
) {
return this.fetchFromCRAN<CranTopDownloadedPackagesRes>(
`/top/${period}/${count.toString()}`,
);
}

static async getTrendingPackages() {
// Only for last week.
const data = await this.fetchFromCRAN<CranTrendingPackagesRes>("/trending");
return data.map((item) => ({
...item,
increase: `${new Number(item.increase).toFixed(0)}%`,
}));
}

/*
* Private.
*/
Expand All @@ -95,13 +119,26 @@ export class PackageInsightService {
// the last day according to its point of reference (likely UTC).
if (days === 1 && !from) {
return this
.fetchFromCRAN<CranDownloadsResponse>`/downloads/total/last-day/${name}`;
.fetchLogsFromCRAN<CranDownloadsResponse>`/downloads/total/last-day/${name}`;
}

const validFrom = from || new Date();
const past = sub(validFrom, { days });
return this
.fetchFromCRAN<CranDownloadsResponse>`/downloads/total/${past}:${validFrom}/${name}`;
.fetchLogsFromCRAN<CranDownloadsResponse>`/downloads/total/${past}:${validFrom}/${name}`;
}

private static async fetchFromCRAN<R extends CranResponse = CranResponse>(
url: string,
): Promise<R> {
return fetch(this.CRAN_LOGS_URL + url, {
headers: { "Content-Type": "application/json" },
})
.then((response) => response.json())
.catch((error) => {
slog.error("Failed to fetch CRAN statistics", error);
return undefined;
});
}

/**
Expand All @@ -112,18 +149,12 @@ export class PackageInsightService {
* @param params
* @returns
*/
private static async fetchFromCRAN<R extends CranResponse = CranResponse>(
private static async fetchLogsFromCRAN<R extends CranResponse = CranResponse>(
template: TemplateStringsArray,
...params: (string | Date)[]
): Promise<R> {
const url = this.getCRANLogsUrl(template, ...params);
return fetch(url, {
headers: {
"Content-Type": "application/json",
},
})
.then((response) => response.json())
.catch(() => undefined);
return this.fetchFromCRAN<R>(url);
}

/**
Expand Down Expand Up @@ -152,7 +183,7 @@ export class PackageInsightService {
return format(part, "yyyy-MM-dd");
});

return "https://cranlogs.r-pkg.org" + stringified.join("");
return stringified.join("");
}

private static format1kDelimiter(total: number) {
Expand Down
11 changes: 11 additions & 0 deletions web/app/data/package-insight.shape.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { z } from "zod";

export const topDownloadedPackagesRangeSchema = z.union([
z.literal("last-day"),
z.literal("last-week"),
z.literal("last-month"),
]);

export type TopDownloadedPackagesRange = z.infer<
typeof topDownloadedPackagesRangeSchema
>;
21 changes: 20 additions & 1 deletion web/app/data/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,26 @@ export type CranDownloadsResponse = Array<{
package: string;
}>;

export type CranResponse = CranDownloadsResponse;
export type CranTopDownloadedPackagesRes = {
start: string; // e.g. "2015-05-01T00:00:00.000Z";
end: string; // e.g. "2015-05-01T00:00:00.000Z";
downloads: Array<{ package: string; downloads: number }>;
};

/**
* Trending packages are the ones that were downloaded at least 1000 times during last week,
* and that substantially increased their download counts, compared to the average weekly downloads in the previous 24 weeks.
* The percentage of increase is also shown in the output.
*/
export type CranTrendingPackagesRes = Array<{
package: string;
increase: number;
}>;

export type CranResponse =
| CranDownloadsResponse
| CranTopDownloadedPackagesRes
| CranTrendingPackagesRes;

export type PackageDownloadTrend = {
trend: string;
Expand Down
9 changes: 9 additions & 0 deletions web/app/modules/anchors.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,12 @@ export function AnchorLink(props: PropsWithChildren<{ fragment: string }>) {
}

AnchorLink.displayName = "AnchorLink";

export function composeAnchorItems(
anchors: string[],
): Array<{ name: string; slug: string }> {
return anchors.map((anchor) => ({
name: anchor,
slug: encodeURIComponent(anchor.toLowerCase().replaceAll(" ", "-")),
}));
}
17 changes: 17 additions & 0 deletions web/app/modules/provided-by-label.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { RiExternalLinkLine } from "@remixicon/react";
import { ExternalLink } from "./external-link";

export function DataProvidedByCRANLabel() {
return (
<p className="text-gray-dim mt-16 text-right text-xs">
Data provided by{" "}
<ExternalLink
href="https://github.com/r-hub/cranlogs.app"
className="inline-flex items-center gap-1 underline underline-offset-4"
>
cranlogs
<RiExternalLinkLine size={10} className="text-gray-dim" />
</ExternalLink>
</p>
);
}
12 changes: 9 additions & 3 deletions web/app/root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,17 +17,23 @@ import "./tailwind.css";
import { ENV } from "./data/env";
import { BASE_URL } from "./modules/app";
import { useEffect } from "react";
import { randomInt } from "es-toolkit";

export const meta: MetaFunction = () => {
export const meta: MetaFunction = ({ location }) => {
// Pseudo-randomly select a cover image based on the length
// of the current path (= stable index per site) and add
// the current day of the week as a seed so that the cover
// changes daily.
const dayOfWeek = new Date().getDay();
const coverIndex = ((location.pathname.length + dayOfWeek) & 9) + 1;

return [
{ title: "CRAN/E" },
{ name: "description", content: "The R package search engine, enhanced" },
{ property: "og:type", content: "website" },
{ property: "og:url", content: BASE_URL },
{
property: "og:image",
content: BASE_URL + `/images/og/cover-${randomInt(9) + 1}.jpg`,
content: BASE_URL + `/images/og/cover-${coverIndex}.jpg`,
},
];
};
Expand Down
5 changes: 5 additions & 0 deletions web/app/routes/$slug[.xml]._index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@ export async function loader(props: LoaderFunctionArgs) {
lastmod: today,
changefreq: "daily",
})}
${composeUrlElement({
path: `/statistic/package`,
lastmod: today,
changefreq: "daily",
})}
</urlset>`.trim(),
{
headers: {
Expand Down
50 changes: 23 additions & 27 deletions web/app/routes/_page.package.$packageId.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,10 @@ import {
} from "../modules/meta";
import { BASE_URL } from "../modules/app";
import { uniq } from "es-toolkit";
import { PackageInsightService } from "../data/package-insight-service.server";
import { PackageInsightService } from "../data/package-insight.service.server";
import { slog } from "../modules/observability.server";
import clsx from "clsx";
import { DataProvidedByCRANLabel } from "../modules/provided-by-label";

const PackageDependencySearch = lazy(() =>
import("../modules/package-dependency-search").then((mod) => ({
Expand Down Expand Up @@ -269,22 +270,26 @@ function AboveTheFoldSection(props: { item: Pkg; lastRelease: string }) {
</ExternalLinkPill>
</li>
) : null}
<li>
<ExternalLinkPill
href={item.cran_checks.link}
icon={<RiExternalLinkLine size={18} />}
>
{item.cran_checks.label}
</ExternalLinkPill>
</li>
<li>
<ExternalLinkPill
href={item.reference_manual.link}
icon={<RiFilePdf2Line size={18} />}
>
{item.reference_manual.label}
</ExternalLinkPill>
</li>
{item.cran_checks ? (
<li>
<ExternalLinkPill
href={item.cran_checks.link}
icon={<RiExternalLinkLine size={18} />}
>
{item.cran_checks.label}
</ExternalLinkPill>
</li>
) : null}
{item.reference_manual ? (
<li>
<ExternalLinkPill
href={item.reference_manual.link}
icon={<RiFilePdf2Line size={18} />}
>
{item.reference_manual.label}
</ExternalLinkPill>
</li>
) : null}
</ul>
<ul className="flex flex-wrap items-start gap-4">
<li>
Expand Down Expand Up @@ -525,16 +530,7 @@ function InsightsPageContentSection(props: {
<p className="text-gray-dim">No downloads available</p>
)}

<p className="text-gray-dim mt-16 text-right text-xs">
Data provided by{" "}
<ExternalLink
href="https://github.com/r-hub/cranlogs.app"
className="inline-flex items-center gap-1 underline underline-offset-4"
>
cranlogs
<RiExternalLinkLine size={10} className="text-gray-dim" />
</ExternalLink>
</p>
<DataProvidedByCRANLabel />
</PageContentSection>
);
}
Expand Down
38 changes: 31 additions & 7 deletions web/app/routes/_page.statistic._index.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import { Link } from "@remix-run/react";
import { Anchors, AnchorLink } from "../modules/anchors";
import { Anchors, AnchorLink, composeAnchorItems } from "../modules/anchors";
import { PageContent } from "../modules/page-content";
import { PageContentSection } from "../modules/page-content-section";
import { InfoCard } from "../modules/info-card";
import { Header } from "../modules/header";
import { Tag } from "../modules/tag";
import { mergeMeta } from "../modules/meta";
import { Separator } from "../modules/separator";

const anchors = ["Site usage"];
const anchors = composeAnchorItems(["Site usage", "CRAN data"]);

export const meta = mergeMeta(() => {
return [
Expand All @@ -27,9 +28,9 @@ export default function StatisticsOverviewPage() {
/>

<Anchors>
{anchors.map((anchor) => (
<AnchorLink key={anchor} fragment={anchor.toLowerCase()}>
{anchor}
{anchors.map(({ name, slug }) => (
<AnchorLink key={slug} fragment={slug}>
{name}
</AnchorLink>
))}
</Anchors>
Expand All @@ -38,11 +39,11 @@ export default function StatisticsOverviewPage() {
<PageContentSection
headline="Site usage"
subline="See what packages and authors are trending on CRAN/E"
fragment="site-usage"
fragment={"site-usage"}
>
<ul className="grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-4">
<li>
<Link to="/statistic/crane/page-visits">
<Link prefetch="intent" to="/statistic/crane/page-visits">
<InfoCard variant="bronze" icon="internal" className="min-h-60">
<div className="space-y-2">
<h3>Page trends</h3>
Expand All @@ -55,6 +56,29 @@ export default function StatisticsOverviewPage() {
</li>
</ul>
</PageContentSection>

<Separator />

<PageContentSection
headline="CRAN data"
subline="Get insights into CRAN data"
fragment={"cran-data"}
>
<ul className="grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-4">
<li>
<Link prefetch="intent" to="/statistic/packages">
<InfoCard variant="bronze" icon="internal" className="min-h-60">
<div className="space-y-2">
<h3>Package downloads</h3>
<p className="text-gray-dim">
See what packages are trending on CRAN/E.
</p>
</div>
</InfoCard>
</Link>
</li>
</ul>
</PageContentSection>
</PageContent>
</>
);
Expand Down
Loading