|
1 | | -import { format, sub } from "date-fns"; |
| 1 | +import { addHours, format } from "date-fns"; |
| 2 | +import { ExpiringSearchIndex } from "./types"; |
2 | 3 | import { |
3 | 4 | CranDownloadsResponse, |
4 | 5 | CranResponse, |
5 | 6 | CranTopDownloadedPackagesRes, |
6 | 7 | CranTrendingPackagesRes, |
7 | | - PackageDownloadTrend, |
8 | | -} from "./types"; |
9 | | -import { TopDownloadedPackagesRange } from "./package-insight.shape"; |
| 8 | + TopDownloadedPackagesRange, |
| 9 | +} from "./package-insight.shape"; |
10 | 10 | import { slog } from "../modules/observability.server"; |
11 | 11 |
|
12 | 12 | export class PackageInsightService { |
13 | 13 | private static readonly CRAN_LOGS_URL = "https://cranlogs.r-pkg.org"; |
14 | 14 |
|
15 | | - /** |
16 | | - * Get the downloads for a package in the last n days, starting |
17 | | - * from today. The result is an array of objects with the number |
18 | | - * of downloads, the relative trend for the respective time period |
19 | | - * and the label. |
20 | | - * |
21 | | - * @param name The name of the package. |
22 | | - * @returns The downloads for the package. |
23 | | - */ |
24 | | - static async getDownloadsWithTrends( |
25 | | - name: string, |
26 | | - ): Promise<PackageDownloadTrend[]> { |
27 | | - const getDownloads = async (days: number, from?: Date) => { |
28 | | - const res = await this.getPackageDownloadsLastNDays({ |
29 | | - name, |
30 | | - days, |
31 | | - from, |
32 | | - }); |
33 | | - return res?.[0]?.downloads; |
| 15 | + private static trendingPackages: ExpiringSearchIndex<CranTrendingPackagesRes> = |
| 16 | + { |
| 17 | + index: [], |
| 18 | + expiresAt: 0, |
34 | 19 | }; |
35 | 20 |
|
36 | | - // Fetch all statistics in parallel. |
37 | | - const now = new Date(); |
38 | | - const [stats, trendReferences] = await Promise.all([ |
39 | | - Promise.all([ |
40 | | - getDownloads(1), |
41 | | - getDownloads(7, now), |
42 | | - getDownloads(30, now), |
43 | | - getDownloads(90, now), |
44 | | - getDownloads(365, now), |
45 | | - ]), |
46 | | - Promise.all([ |
47 | | - getDownloads(0, sub(now, { days: 2 })), |
48 | | - getDownloads(7, sub(now, { days: 7 })), |
49 | | - getDownloads(30, sub(now, { days: 30 })), |
50 | | - getDownloads(90, sub(now, { days: 90 })), |
51 | | - getDownloads(365, sub(now, { days: 365 })), |
52 | | - ]), |
53 | | - ]); |
54 | | - |
55 | | - // Get rend in percentage. |
56 | | - const trends = stats.map((stat, i) => { |
57 | | - const ref = trendReferences[i]; |
58 | | - // No valid values. |
59 | | - if (stat === undefined || ref === undefined || ref === 0) { |
60 | | - return ""; |
61 | | - } |
62 | | - const diff = stat - ref; |
63 | | - return `${diff > 0 ? "+" : ""}${((diff / ref) * 100).toFixed(0)}%`; |
64 | | - }); |
65 | | - |
66 | | - // Aggregate the statistics into a single object. |
67 | | - const labels = [ |
68 | | - "Yesterday", |
69 | | - "Last 7 days", |
70 | | - "Last 30 days", |
71 | | - "Last 90 days", |
72 | | - "Last 365 days", |
73 | | - ]; |
74 | | - const downloads = stats |
75 | | - .map((value, i) => ({ |
76 | | - value, |
77 | | - trend: trends[i], |
78 | | - label: labels[i], |
79 | | - })) |
80 | | - .filter(({ value }) => value !== undefined) |
81 | | - .map(({ value, ...rest }) => ({ |
82 | | - value: this.format1kDelimiter(value), |
83 | | - ...rest, |
84 | | - })); |
85 | | - |
86 | | - return downloads; |
87 | | - } |
88 | | - |
89 | 21 | static async getTopDownloadedPackages( |
90 | 22 | period: TopDownloadedPackagesRange, |
91 | 23 | count: number, |
92 | 24 | ) { |
93 | | - return this.fetchFromCRAN<CranTopDownloadedPackagesRes>( |
| 25 | + return await this.fetchFromCRAN<CranTopDownloadedPackagesRes>( |
94 | 26 | `/top/${period}/${count.toString()}`, |
95 | 27 | ); |
96 | 28 | } |
97 | 29 |
|
98 | 30 | static async getTrendingPackages() { |
99 | | - // Only for last week. |
100 | | - const data = await this.fetchFromCRAN<CranTrendingPackagesRes>("/trending"); |
101 | | - return data.map((item) => ({ |
102 | | - ...item, |
103 | | - increase: `${new Number(item.increase).toFixed(0)}%`, |
104 | | - })); |
105 | | - } |
106 | | - |
107 | | - /* |
108 | | - * Private. |
109 | | - */ |
110 | | - |
111 | | - private static async getPackageDownloadsLastNDays(params: { |
112 | | - name: string; |
113 | | - days: number; |
114 | | - from?: Date; |
115 | | - }) { |
116 | | - const { name, days, from } = params; |
| 31 | + // Check if the index has expired. |
| 32 | + if (this.trendingPackages.expiresAt < Date.now()) { |
| 33 | + // Only for last week. |
| 34 | + const data = await this.fetchFromCRAN<CranTrendingPackagesRes>( |
| 35 | + "/trending", |
| 36 | + ).then((data) => { |
| 37 | + return data.map((item) => ({ |
| 38 | + ...item, |
| 39 | + increase: `${new Number(item.increase).toFixed(0)}%`, |
| 40 | + })); |
| 41 | + }); |
117 | 42 |
|
118 | | - // Special case as the logs-API returns data earliest for |
119 | | - // the last day according to its point of reference (likely UTC). |
120 | | - if (days === 1 && !from) { |
121 | | - return this |
122 | | - .fetchLogsFromCRAN<CranDownloadsResponse>`/downloads/total/last-day/${name}`; |
| 43 | + this.trendingPackages = { |
| 44 | + index: data, |
| 45 | + expiresAt: addHours(new Date(), 6).getTime(), |
| 46 | + }; |
123 | 47 | } |
124 | 48 |
|
125 | | - const validFrom = from || new Date(); |
126 | | - const past = sub(validFrom, { days }); |
| 49 | + return this.trendingPackages.index; |
| 50 | + } |
| 51 | + |
| 52 | + static async getDailyDownloadsForPackage( |
| 53 | + name: string, |
| 54 | + range: TopDownloadedPackagesRange, |
| 55 | + ): Promise<CranDownloadsResponse> { |
127 | 56 | return this |
128 | | - .fetchLogsFromCRAN<CranDownloadsResponse>`/downloads/total/${past}:${validFrom}/${name}`; |
| 57 | + .fetchLogsFromCRAN<CranDownloadsResponse>`/downloads/daily/${range}/${name}`; |
129 | 58 | } |
130 | 59 |
|
| 60 | + /* |
| 61 | + * Private. |
| 62 | + */ |
| 63 | + |
131 | 64 | private static async fetchFromCRAN<R extends CranResponse = CranResponse>( |
132 | 65 | url: string, |
133 | 66 | ): Promise<R> { |
|
0 commit comments