cleaning repo and saving update

This commit is contained in:
Luke Hagar
2025-08-14 16:02:33 +00:00
parent 30aedd3794
commit ca7d86ed54
26 changed files with 1904 additions and 473 deletions

25
.devcontainer/Dockerfile Normal file
View File

@@ -0,0 +1,25 @@
FROM node:22
# Install pnpm via corepack; keep git available for typical workflows
# Also install native build deps required by node-canvas used for server-side chart rendering
RUN corepack enable && corepack prepare pnpm@9.12.3 --activate \
&& apt-get update \
&& apt-get install -y \
git \
build-essential \
libcairo2-dev \
libpango1.0-dev \
libjpeg-dev \
libgif-dev \
librsvg2-dev \
libpng-dev \
libpixman-1-dev \
pkg-config \
python3 \
&& rm -rf /var/lib/apt/lists/*
USER node
WORKDIR /workspace

View File

@@ -1,33 +1,18 @@
{
"name": "pypistats.dev",
"dockerComposeFile": [
"../docker-compose.yml",
"../docker-compose.dev.yml"
],
"service": "web",
"workspaceFolder": "/app",
"build": {
"args": {
"SKIP_APP_BUILD": "1"
}
"name": "Vite Dev + Postgres + Redis",
"dockerComposeFile": "docker-compose.yml",
"service": "app",
"workspaceFolder": "/workspace",
"settings": {
"terminal.integrated.defaultProfile.linux": "bash"
},
"runArgs": ["--env-file", ".env"],
"forwardPorts": [5173, 3000, 5555],
"portsAttributes": {
"5173": { "label": "Vite Dev Server" },
"3000": { "label": "Node Adapter Server" },
"5555": { "label": "Prisma Studio" }
},
"customizations": {
"vscode": {
"extensions": [
"esbenp.prettier-vscode",
"Prisma.prisma",
"svelte.svelte-vscode",
"dbaeumer.vscode-eslint"
]
}
}
"postCreateCommand": "corepack enable && corepack prepare pnpm@9.12.3 --activate && pnpm install && pnpm prisma generate && pnpm prisma migrate deploy && (pnpm rebuild canvas || true) && pnpm dev",
"remoteUser": "root",
"forwardPorts": [5173, 5432, 6379],
"extensions": [
"esbenp.prettier-vscode",
"dbaeumer.vscode-eslint"
]
}

View File

@@ -0,0 +1,47 @@
version: "3.8"
services:
app:
build:
context: .
dockerfile: Dockerfile
volumes:
- ..:/workspace:cached
command: sleep infinity
depends_on:
db:
condition: service_started
redis:
condition: service_started
ports:
- "5173:5173"
environment:
# Use service DNS names within the devcontainer network
DATABASE_URL: postgresql://postgres:postgres@db:5432/postgres?schema=public
REDIS_URL: redis://redis:6379
networks:
- devnet
db:
image: postgres:15
restart: unless-stopped
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: postgres
ports:
- "5432:5432"
networks:
- devnet
redis:
image: redis:7
restart: unless-stopped
ports:
- "6379:6379"
networks:
- devnet
networks:
devnet:

1
.gitignore vendored
View File

@@ -1,4 +1,5 @@
node_modules
.pnpm-store
# Output
.output

View File

@@ -1,13 +1,10 @@
FROM node:20-slim
FROM node:latest
# Install deps needed by Prisma and shell
RUN apt-get update && apt-get install -y openssl bash && rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Allow skipping app build in devcontainer
ARG SKIP_APP_BUILD=0
# Copy package manifests first for better cache
COPY package.json pnpm-lock.yaml* ./
@@ -21,17 +18,15 @@ RUN pnpm install --frozen-lockfile
COPY . .
# Generate Prisma client and build SvelteKit (Node adapter)
RUN pnpm prisma generate
RUN if [ "$SKIP_APP_BUILD" != "1" ]; then pnpm build; fi
RUN pnpm prisma generate && pnpm prisma migrate deploy
RUN pnpm build
ENV NODE_ENV=production
# Entrypoint handles migrations and start
COPY docker/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
EXPOSE 3000
ENTRYPOINT ["/entrypoint.sh"]
# Default command can be overridden by compose
CMD ["node", "build/index.js"]

View File

@@ -1,26 +0,0 @@
version: '3.9'
services:
web:
build:
context: .
args:
SKIP_APP_BUILD: "1"
command: sh -lc "\
corepack enable && corepack prepare pnpm@9.12.3 --activate && \
pnpm install && \
pnpm prisma generate && \
pnpm prisma migrate deploy || true && \
pnpm dev --host 0.0.0.0 --port 5173"
volumes:
- ./:/app
- web_node_modules:/app/node_modules
env_file:
- .env
ports:
- "5173:5173"
volumes:
web_node_modules:

View File

@@ -1,8 +1,6 @@
version: '3.9'
services:
db:
image: postgres:16
image: postgres:latest
environment:
POSTGRES_DB: pypistats
POSTGRES_USER: pypistats
@@ -12,13 +10,13 @@ services:
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
test: ["CMD-SHELL", "pg_isready -U $POSTGRES_USER -d $POSTGRES_DB"]
interval: 5s
timeout: 5s
retries: 20
redis:
image: redis:7
image: redis:latest
ports:
- "6379:6379"
healthcheck:
@@ -45,7 +43,7 @@ services:
# GOOGLE_APPLICATION_CREDENTIALS_JSON: '{"type":"service_account",...}'
ports:
- "3000:3000"
command: ["/entrypoint.sh"]
command: ["node", "build/index.js"]
volumes:
pgdata:

View File

@@ -1,71 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# Wait for Postgres if DATABASE_URL is provided
if [[ -n "${DATABASE_URL:-}" ]]; then
echo "Waiting for database..."
ATTEMPTS=0
until node -e "const { Client } = require('pg'); (async () => { try { const c=new Client({ connectionString: process.env.DATABASE_URL }); await c.connect(); await c.end(); process.exit(0);} catch(e){ process.exit(1);} })()" >/dev/null 2>&1; do
ATTEMPTS=$((ATTEMPTS+1))
if [[ $ATTEMPTS -gt 60 ]]; then
echo "Database did not become ready in time" >&2
exit 1
fi
sleep 1
done
fi
# Run Prisma migrations (safe for prod) with retry
if [[ "${RUN_DB_MIGRATIONS:-1}" == "1" ]]; then
echo "Running prisma migrate deploy..."
ATTEMPTS=0
until pnpm prisma migrate deploy; do
ATTEMPTS=$((ATTEMPTS+1))
if [[ $ATTEMPTS -gt 10 ]]; then
echo "Prisma migrate failed after retries" >&2
exit 1
fi
echo "Retrying migrations in 3s..."
sleep 3
done
fi
# Start the app (SvelteKit Node adapter)
exec node build/index.js
#!/usr/bin/env bash
set -euo pipefail
# Wait for Postgres if DATABASE_URL is provided
if [[ -n "${DATABASE_URL:-}" ]]; then
echo "Waiting for database..."
ATTEMPTS=0
until node -e "const { Client } = require('pg'); (async () => { try { const c=new Client({ connectionString: process.env.DATABASE_URL }); await c.connect(); await c.end(); process.exit(0);} catch(e){ process.exit(1);} })()" >/dev/null 2>&1; do
ATTEMPTS=$((ATTEMPTS+1))
if [[ $ATTEMPTS -gt 60 ]]; then
echo "Database did not become ready in time" >&2
exit 1
fi
sleep 1
done
fi
# Run Prisma migrations (safe for prod) with retry
if [[ "${RUN_DB_MIGRATIONS:-1}" == "1" ]]; then
echo "Running prisma migrate deploy..."
ATTEMPTS=0
until pnpm prisma migrate deploy; do
ATTEMPTS=$((ATTEMPTS+1))
if [[ $ATTEMPTS -gt 10 ]]; then
echo "Prisma migrate failed after retries" >&2
exit 1
fi
echo "Retrying migrations in 3s..."
sleep 3
done
fi
# Start the app (SvelteKit Node adapter)
exec node build/index.js

270
openapi.yaml Normal file
View File

@@ -0,0 +1,270 @@
openapi: 3.0.3
info:
title: PyPI Stats API
version: 1.0.0
description: |
API for querying download statistics for Python packages from PyPI (via BigQuery replication).
Endpoints return JSON or rendered charts. Caching and on-demand freshness are applied.
servers:
- url: https://{host}
variables:
host:
default: localhost:5173
paths:
/api/packages/{package}/recent:
get:
summary: Recent downloads summary (day, week, month)
parameters:
- in: path
name: package
required: true
schema: { type: string }
description: Package name (dots/underscores normalized to hyphens)
- in: query
name: period
schema: { type: string, enum: [day, week, month] }
description: Optional; if omitted returns all three periods
responses:
'200':
description: Success
content:
application/json:
schema:
type: object
properties:
package: { type: string }
type: { type: string, enum: [recent_downloads] }
data:
type: object
example:
last_day: 123
last_week: 456
last_month: 789
'404': { description: Package not found }
/api/packages/{package}/overall:
get:
summary: Overall downloads time series
parameters:
- in: path
name: package
required: true
schema: { type: string }
- in: query
name: mirrors
schema: { type: string, enum: ['true', 'false'] }
description: Include mirror downloads; omit for both categories
responses:
'200':
description: Success
content:
application/json:
schema:
type: object
properties:
package: { type: string }
type: { type: string, enum: [overall_downloads] }
data:
type: array
items:
type: object
properties:
date: { type: string, format: date }
category: { type: string, enum: [with_mirrors, without_mirrors] }
downloads: { type: integer }
'404': { description: Package not found }
/api/packages/{package}/python_major:
get:
summary: Python major version downloads time series
parameters:
- in: path
name: package
required: true
schema: { type: string }
- in: query
name: version
schema: { type: string }
description: Optional filter (e.g., '3')
responses:
'200':
description: Success
content:
application/json:
schema:
type: object
properties:
package: { type: string }
type: { type: string, enum: [python_major_downloads] }
data:
type: array
items:
type: object
properties:
date: { type: string, format: date }
category: { type: string }
downloads: { type: integer }
/api/packages/{package}/python_minor:
get:
summary: Python minor version downloads time series
parameters:
- in: path
name: package
required: true
schema: { type: string }
- in: query
name: version
schema: { type: string }
description: Optional filter (e.g., '3.11')
responses:
'200':
description: Success
content:
application/json:
schema:
type: object
properties:
package: { type: string }
type: { type: string, enum: [python_minor_downloads] }
data:
type: array
items:
type: object
properties:
date: { type: string, format: date }
category: { type: string }
downloads: { type: integer }
/api/packages/{package}/system:
get:
summary: System OS downloads time series
parameters:
- in: path
name: package
required: true
schema: { type: string }
- in: query
name: os
schema: { type: string, enum: [Windows, Linux, Darwin, other] }
description: Optional filter
responses:
'200':
description: Success
content:
application/json:
schema:
type: object
properties:
package: { type: string }
type: { type: string, enum: [system_downloads] }
data:
type: array
items:
type: object
properties:
date: { type: string, format: date }
category: { type: string }
downloads: { type: integer }
/api/packages/{package}/installer:
get:
summary: Installer downloads time series
parameters:
- in: path
name: package
required: true
schema: { type: string }
responses:
'200':
description: Success
content:
application/json:
schema:
type: object
properties:
package: { type: string }
type: { type: string, enum: [installer_downloads] }
data:
type: array
items:
type: object
properties:
date: { type: string, format: date }
category: { type: string }
downloads: { type: integer }
/api/packages/{package}/summary:
get:
summary: At-a-glance totals for a package
parameters:
- in: path
name: package
required: true
schema: { type: string }
responses:
'200':
description: Success
content:
application/json:
schema:
type: object
properties:
package: { type: string }
type: { type: string, enum: [summary] }
totals:
type: object
properties:
overall: { type: integer }
system:
type: object
additionalProperties: { type: integer }
python_major:
type: object
additionalProperties: { type: integer }
python_minor:
type: object
additionalProperties: { type: integer }
/api/packages/{package}/chart/{type}:
get:
summary: Render a Chart.js image of time series
parameters:
- in: path
name: package
required: true
schema: { type: string }
- in: path
name: type
required: true
schema: { type: string, enum: [overall, python_major, python_minor, system] }
- in: query
name: chart
schema: { type: string, enum: [line, bar] }
description: Chart type to render
- in: query
name: mirrors
schema: { type: string, enum: ['true', 'false'] }
description: Only for type=overall
- in: query
name: version
schema: { type: string }
description: Only for type=python_major/python_minor (e.g., 3 or 3.11)
- in: query
name: os
schema: { type: string, enum: [Windows, Linux, Darwin, other] }
description: Only for type=system
responses:
'200':
description: PNG image
content:
image/png:
schema:
type: string
format: binary
'400': { description: Bad request }
'404': { description: Package not found }
components:
securitySchemes: {}

View File

@@ -4,9 +4,9 @@
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "svelte-kit build",
"start": "node build/index.js",
"dev": "vite dev",
"build": "svelte-kit build",
"start": "node build/index.js",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
@@ -20,10 +20,13 @@
"dependencies": {
"@google-cloud/bigquery": "^8.1.1",
"@prisma/client": "^6.13.0",
"@sveltejs/adapter-node": "^5.2.8",
"@types/node-cron": "^3.0.11",
"@sveltejs/adapter-node": "^5.2.8",
"@types/node-cron": "^3.0.11",
"node-cron": "^4.2.1",
"redis": "^5.7.0"
"redis": "^5.7.0",
"chart.js": "^4.4.4",
"chartjs-node-canvas": "^5.0.0",
"canvas": "^3.1.2"
},
"devDependencies": {
"@sveltejs/adapter-auto": "^6.0.1",
@@ -45,7 +48,8 @@
},
"pnpm": {
"onlyBuiltDependencies": [
"esbuild"
"esbuild",
"canvas"
]
}
}
}

231
pnpm-lock.yaml generated
View File

@@ -20,6 +20,15 @@ importers:
'@types/node-cron':
specifier: ^3.0.11
version: 3.0.11
canvas:
specifier: ^3.1.2
version: 3.1.2
chart.js:
specifier: ^4.4.4
version: 4.5.0
chartjs-node-canvas:
specifier: ^5.0.0
version: 5.0.0(chart.js@4.5.0)
node-cron:
specifier: ^4.2.1
version: 4.2.1
@@ -291,6 +300,9 @@ packages:
'@jridgewell/trace-mapping@0.3.29':
resolution: {integrity: sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==}
'@kurkle/color@0.3.4':
resolution: {integrity: sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==}
'@polka/url@1.0.0-next.29':
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
@@ -708,9 +720,15 @@ packages:
bignumber.js@9.3.1:
resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==}
bl@4.1.0:
resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==}
buffer-equal-constant-time@1.0.1:
resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==}
buffer@5.7.1:
resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==}
c12@3.1.0:
resolution: {integrity: sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==}
peerDependencies:
@@ -723,10 +741,26 @@ packages:
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
engines: {node: '>= 0.4'}
canvas@3.1.2:
resolution: {integrity: sha512-Z/tzFAcBzoCvJlOSlCnoekh1Gu8YMn0J51+UAuXJAbW1Z6I9l2mZgdD7738MepoeeIcUdDtbMnOg6cC7GJxy/g==}
engines: {node: ^18.12.0 || >= 20.9.0}
chart.js@4.5.0:
resolution: {integrity: sha512-aYeC/jDgSEx8SHWZvANYMioYMZ2KX02W6f6uVfyteuCGcadDLcYVHdfdygsTQkQ4TKn5lghoojAsPj5pu0SnvQ==}
engines: {pnpm: '>=8'}
chartjs-node-canvas@5.0.0:
resolution: {integrity: sha512-+Lc5phRWjb+UxAIiQpKgvOaG6Mw276YQx2jl2BrxoUtI3A4RYTZuGM5Dq+s4ReYmCY42WEPSR6viF3lDSTxpvw==}
peerDependencies:
chart.js: ^4.4.8
chokidar@4.0.3:
resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
engines: {node: '>= 14.16.0'}
chownr@1.1.4:
resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==}
chownr@3.0.0:
resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==}
engines: {node: '>=18'}
@@ -778,6 +812,14 @@ packages:
supports-color:
optional: true
decompress-response@6.0.0:
resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==}
engines: {node: '>=10'}
deep-extend@0.6.0:
resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==}
engines: {node: '>=4.0.0'}
deepmerge-ts@7.1.5:
resolution: {integrity: sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==}
engines: {node: '>=16.0.0'}
@@ -857,6 +899,10 @@ packages:
estree-walker@2.0.2:
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
expand-template@2.0.3:
resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==}
engines: {node: '>=6'}
exsolve@1.0.7:
resolution: {integrity: sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw==}
@@ -891,6 +937,9 @@ packages:
resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==}
engines: {node: '>=12.20.0'}
fs-constants@1.0.0:
resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==}
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -919,6 +968,9 @@ packages:
resolution: {integrity: sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==}
hasBin: true
github-from-package@0.0.0:
resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==}
google-auth-library@10.2.1:
resolution: {integrity: sha512-HMxFl2NfeHYnaL1HoRIN1XgorKS+6CDaM+z9LSSN+i/nKDDL4KFFEWogMXu7jV4HZQy2MsxpY+wA5XIf3w410A==}
engines: {node: '>=18'}
@@ -969,6 +1021,9 @@ packages:
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
engines: {node: '>= 14'}
ieee754@1.2.1:
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
index-to-position@1.1.0:
resolution: {integrity: sha512-XPdx9Dq4t9Qk1mTMbWONJqU7boCoumEH7fRET37HX5+khDUl3J2W6PdALxhILYlIYx2amlwYcRPp28p0tSiojg==}
engines: {node: '>=18'}
@@ -976,6 +1031,9 @@ packages:
inherits@2.0.4:
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
ini@1.3.8:
resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==}
is-core-module@2.16.1:
resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==}
engines: {node: '>= 0.4'}
@@ -1108,10 +1166,17 @@ packages:
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
engines: {node: '>= 0.6'}
mimic-response@3.1.0:
resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==}
engines: {node: '>=10'}
mini-svg-data-uri@1.4.4:
resolution: {integrity: sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==}
hasBin: true
minimist@1.2.8:
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
minipass@7.1.2:
resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==}
engines: {node: '>=16 || 14 >=14.17'}
@@ -1120,6 +1185,9 @@ packages:
resolution: {integrity: sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA==}
engines: {node: '>= 18'}
mkdirp-classic@0.5.3:
resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==}
mkdirp@3.0.1:
resolution: {integrity: sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==}
engines: {node: '>=10'}
@@ -1141,6 +1209,16 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
napi-build-utils@2.0.0:
resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==}
node-abi@3.75.0:
resolution: {integrity: sha512-OhYaY5sDsIka7H7AtijtI9jwGYLyl29eQn/W623DiN/MIv5sUqc4g7BIDThX+gb7di9f6xK02nkp8sdfFWZLTg==}
engines: {node: '>=10'}
node-addon-api@7.1.1:
resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
node-cron@4.2.1:
resolution: {integrity: sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==}
engines: {node: '>=6.0.0'}
@@ -1203,6 +1281,11 @@ packages:
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
engines: {node: ^10 || ^12 || >=14}
prebuild-install@7.1.3:
resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==}
engines: {node: '>=10'}
hasBin: true
prettier-plugin-svelte@3.4.0:
resolution: {integrity: sha512-pn1ra/0mPObzqoIQn/vUTR3ZZI6UuZ0sHqMK5x2jMLGrs53h0sXhkVuDcrlssHwIMk7FYrMjHBPoUSyyEEDlBQ==}
peerDependencies:
@@ -1292,12 +1375,19 @@ packages:
resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==}
engines: {node: '>=6'}
pump@3.0.3:
resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==}
pure-rand@6.1.0:
resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==}
rc9@2.1.2:
resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==}
rc@1.2.8:
resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==}
hasBin: true
read-package-up@11.0.0:
resolution: {integrity: sha512-MbgfoNPANMdb4oRBNg5eqLbB2t2r+o5Ua1pNt8BqGp4I0FJZhuVSOj3PaBPni4azWuSzEdNn2evevzVmEk1ohQ==}
engines: {node: '>=18'}
@@ -1347,6 +1437,12 @@ packages:
set-cookie-parser@2.7.1:
resolution: {integrity: sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==}
simple-concat@1.0.1:
resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==}
simple-get@4.0.1:
resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==}
sirv@3.0.1:
resolution: {integrity: sha512-FoqMu0NCGBLCcAkS1qA+XJIQTR6/JHfQXl+uGteNCQ76T91DMUjPa9xfmeqMY3z80nLSg9yQmNjK0Px6RWsH/A==}
engines: {node: '>=18'}
@@ -1376,6 +1472,10 @@ packages:
string_decoder@1.3.0:
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
strip-json-comments@2.0.1:
resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==}
engines: {node: '>=0.10.0'}
stubs@3.0.0:
resolution: {integrity: sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw==}
@@ -1402,6 +1502,13 @@ packages:
resolution: {integrity: sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg==}
engines: {node: '>=6'}
tar-fs@2.1.3:
resolution: {integrity: sha512-090nwYJDmlhwFwEW3QQl+vaNnxsO2yVsd45eTKRBzSzu+hlb1w2K9inVq5b0ngXuLVqQ4ApvsUHHnu/zQNkWAg==}
tar-stream@2.2.0:
resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==}
engines: {node: '>=6'}
tar@7.4.3:
resolution: {integrity: sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==}
engines: {node: '>=18'}
@@ -1421,6 +1528,12 @@ packages:
resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==}
engines: {node: '>=6'}
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
tunnel-agent@0.6.0:
resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==}
type-fest@4.41.0:
resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==}
engines: {node: '>=16'}
@@ -1672,6 +1785,8 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.4
'@kurkle/color@0.3.4': {}
'@polka/url@1.0.0-next.29': {}
'@prisma/client@6.13.0(prisma@6.13.0(typescript@5.9.2))(typescript@5.9.2)':
@@ -2027,8 +2142,19 @@ snapshots:
bignumber.js@9.3.1: {}
bl@4.1.0:
dependencies:
buffer: 5.7.1
inherits: 2.0.4
readable-stream: 3.6.2
buffer-equal-constant-time@1.0.1: {}
buffer@5.7.1:
dependencies:
base64-js: 1.5.1
ieee754: 1.2.1
c12@3.1.0:
dependencies:
chokidar: 4.0.3
@@ -2049,10 +2175,27 @@ snapshots:
es-errors: 1.3.0
function-bind: 1.1.2
canvas@3.1.2:
dependencies:
node-addon-api: 7.1.1
prebuild-install: 7.1.3
chart.js@4.5.0:
dependencies:
'@kurkle/color': 0.3.4
chartjs-node-canvas@5.0.0(chart.js@4.5.0):
dependencies:
canvas: 3.1.2
chart.js: 4.5.0
tslib: 2.8.1
chokidar@4.0.3:
dependencies:
readdirp: 4.1.2
chownr@1.1.4: {}
chownr@3.0.0: {}
citty@0.1.6:
@@ -2083,6 +2226,12 @@ snapshots:
dependencies:
ms: 2.1.3
decompress-response@6.0.0:
dependencies:
mimic-response: 3.1.0
deep-extend@0.6.0: {}
deepmerge-ts@7.1.5: {}
deepmerge@4.3.1: {}
@@ -2182,6 +2331,8 @@ snapshots:
estree-walker@2.0.2: {}
expand-template@2.0.3: {}
exsolve@1.0.7: {}
extend@3.0.2: {}
@@ -2214,6 +2365,8 @@ snapshots:
dependencies:
fetch-blob: 3.2.0
fs-constants@1.0.0: {}
fsevents@2.3.3:
optional: true
@@ -2262,6 +2415,8 @@ snapshots:
nypm: 0.6.1
pathe: 2.0.3
github-from-package@0.0.0: {}
google-auth-library@10.2.1:
dependencies:
base64-js: 1.5.1
@@ -2325,10 +2480,14 @@ snapshots:
transitivePeerDependencies:
- supports-color
ieee754@1.2.1: {}
index-to-position@1.1.0: {}
inherits@2.0.4: {}
ini@1.3.8: {}
is-core-module@2.16.1:
dependencies:
hasown: 2.0.2
@@ -2441,14 +2600,20 @@ snapshots:
dependencies:
mime-db: 1.52.0
mimic-response@3.1.0: {}
mini-svg-data-uri@1.4.4: {}
minimist@1.2.8: {}
minipass@7.1.2: {}
minizlib@3.0.2:
dependencies:
minipass: 7.1.2
mkdirp-classic@0.5.3: {}
mkdirp@3.0.1: {}
mri@1.2.0: {}
@@ -2459,6 +2624,14 @@ snapshots:
nanoid@3.3.11: {}
napi-build-utils@2.0.0: {}
node-abi@3.75.0:
dependencies:
semver: 7.7.2
node-addon-api@7.1.1: {}
node-cron@4.2.1: {}
node-domexception@1.0.0: {}
@@ -2524,6 +2697,21 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.1
prebuild-install@7.1.3:
dependencies:
detect-libc: 2.0.4
expand-template: 2.0.3
github-from-package: 0.0.0
minimist: 1.2.8
mkdirp-classic: 0.5.3
napi-build-utils: 2.0.0
node-abi: 3.75.0
pump: 3.0.3
rc: 1.2.8
simple-get: 4.0.1
tar-fs: 2.1.3
tunnel-agent: 0.6.0
prettier-plugin-svelte@3.4.0(prettier@3.6.2)(svelte@5.37.3):
dependencies:
prettier: 3.6.2
@@ -2550,6 +2738,11 @@ snapshots:
prismjs@1.30.0: {}
pump@3.0.3:
dependencies:
end-of-stream: 1.4.5
once: 1.4.0
pure-rand@6.1.0: {}
rc9@2.1.2:
@@ -2557,6 +2750,13 @@ snapshots:
defu: 6.1.4
destr: 2.0.5
rc@1.2.8:
dependencies:
deep-extend: 0.6.0
ini: 1.3.8
minimist: 1.2.8
strip-json-comments: 2.0.1
read-package-up@11.0.0:
dependencies:
find-up-simple: 1.0.1
@@ -2637,6 +2837,14 @@ snapshots:
set-cookie-parser@2.7.1: {}
simple-concat@1.0.1: {}
simple-get@4.0.1:
dependencies:
decompress-response: 6.0.0
once: 1.4.0
simple-concat: 1.0.1
sirv@3.0.1:
dependencies:
'@polka/url': 1.0.0-next.29
@@ -2669,6 +2877,8 @@ snapshots:
dependencies:
safe-buffer: 5.2.1
strip-json-comments@2.0.1: {}
stubs@3.0.0: {}
supports-preserve-symlinks-flag@1.0.0: {}
@@ -2706,6 +2916,21 @@ snapshots:
tapable@2.2.2: {}
tar-fs@2.1.3:
dependencies:
chownr: 1.1.4
mkdirp-classic: 0.5.3
pump: 3.0.3
tar-stream: 2.2.0
tar-stream@2.2.0:
dependencies:
bl: 4.1.0
end-of-stream: 1.4.5
fs-constants: 1.0.0
inherits: 2.0.4
readable-stream: 3.6.2
tar@7.4.3:
dependencies:
'@isaacs/fs-minipass': 4.0.1
@@ -2733,6 +2958,12 @@ snapshots:
totalist@3.0.1: {}
tslib@2.8.1: {}
tunnel-agent@0.6.0:
dependencies:
safe-buffer: 5.2.1
type-fest@4.41.0: {}
typescript@5.9.2: {}

View File

@@ -21,28 +21,38 @@ CREATE TABLE "public"."overall" (
CREATE TABLE "public"."python_major" (
"date" DATE NOT NULL,
"package" TEXT NOT NULL,
"category" TEXT,
"category" TEXT NOT NULL,
"downloads" INTEGER NOT NULL,
CONSTRAINT "python_major_pkey" PRIMARY KEY ("date","package")
CONSTRAINT "python_major_pkey" PRIMARY KEY ("date","package","category")
);
-- CreateTable
CREATE TABLE "public"."python_minor" (
"date" DATE NOT NULL,
"package" TEXT NOT NULL,
"category" TEXT,
"category" TEXT NOT NULL,
"downloads" INTEGER NOT NULL,
CONSTRAINT "python_minor_pkey" PRIMARY KEY ("date","package")
CONSTRAINT "python_minor_pkey" PRIMARY KEY ("date","package","category")
);
-- CreateTable
CREATE TABLE "public"."system" (
"date" DATE NOT NULL,
"package" TEXT NOT NULL,
"category" TEXT,
"category" TEXT NOT NULL,
"downloads" INTEGER NOT NULL,
CONSTRAINT "system_pkey" PRIMARY KEY ("date","package")
CONSTRAINT "system_pkey" PRIMARY KEY ("date","package","category")
);
-- CreateTable
CREATE TABLE "public"."installer" (
"date" DATE NOT NULL,
"package" TEXT NOT NULL,
"category" TEXT NOT NULL,
"downloads" INTEGER NOT NULL,
CONSTRAINT "installer_pkey" PRIMARY KEY ("date","package","category")
);

View File

@@ -1,4 +1,4 @@
import { closeRedisClient, forceDisconnectRedis } from '$lib/redis.js';
import { closeRedisClient, forceDisconnectRedis, getRedisClient } from '$lib/redis.js';
import type { Handle } from '@sveltejs/kit';
// Minimal server hooks without cron
@@ -30,6 +30,60 @@ if (typeof process !== 'undefined') {
});
}
// Sliding window rate limit parameters
const WINDOW_SECONDS = 60; // 1 minute
const MAX_REQUESTS = 300; // per id per window
async function consumeSlidingWindow(key: string, points: number, windowSeconds: number) {
const client = getRedisClient();
if (!client) return { allowed: true, remaining: MAX_REQUESTS };
const now = Date.now();
const windowStart = now - windowSeconds * 1000;
const listKey = `rl:sw:${key}`;
// Remove old entries and push current
const lua = `
local key = KEYS[1]
local now = tonumber(ARGV[1])
local windowStart = tonumber(ARGV[2])
local points = tonumber(ARGV[3])
local limit = tonumber(ARGV[4])
-- Trim old timestamps
redis.call('ZREMRANGEBYSCORE', key, 0, windowStart)
-- Add current request timestamps (as single timestamp repeated 'points' times)
for i=1,points do
redis.call('ZADD', key, now, now .. '-' .. i)
end
local count = redis.call('ZCARD', key)
-- Set expiry just beyond window
redis.call('EXPIRE', key, windowStart + (60*60*24) == 0 and 60 or math.floor((now - windowStart)/1000) + 5)
return count
`;
const count = (await client.eval(lua, {
keys: [listKey],
arguments: [String(now), String(windowStart), String(points), String(MAX_REQUESTS)]
})) as number;
return { allowed: count <= MAX_REQUESTS, remaining: Math.max(0, MAX_REQUESTS - count) };
}
export const handle: Handle = async ({ event, resolve }) => {
// Only apply to API routes
if (event.url.pathname.startsWith('/api/')) {
// Identify client by IP (or forwarded-for)
const ip = (event.getClientAddress?.() || event.request.headers.get('x-forwarded-for') || '').split(',')[0].trim() || 'unknown';
const key = `ip:${ip}`;
const result = await consumeSlidingWindow(key, 1, WINDOW_SECONDS);
if (!result.allowed) {
const reset = WINDOW_SECONDS; // approximate
return new Response('Too Many Requests', {
status: 429,
headers: {
'Retry-After': String(reset),
'RateLimit-Policy': `${MAX_REQUESTS};w=${WINDOW_SECONDS}`,
'RateLimit-Remaining': '0'
}
});
}
}
return resolve(event);
};

View File

@@ -1,8 +1,21 @@
import { prisma } from './prisma.js';
import { RECENT_CATEGORIES } from './database.js';
import { CacheManager } from './redis.js';
import { DataProcessor } from './data-processor.js';
const cache = new CacheManager();
let processor: DataProcessor | null = null;
function getProcessor() {
if (!processor) processor = new DataProcessor();
return processor;
}
async function ensurePackageFreshnessFor(packageName: string) {
try {
await getProcessor().ensurePackageFreshness(packageName);
} catch (error) {
console.error('Failed to ensure package freshness:', packageName, error);
}
}
export type Results = {
date: string;
@@ -13,11 +26,10 @@ export type Results = {
export async function getRecentDownloads(packageName: string, category?: string): Promise<Results[]> {
const cacheKey = CacheManager.getRecentStatsKey(packageName);
// Try to get from cache first
const cached = await cache.get<Results[]>(cacheKey);
if (cached && !category) {
return cached;
}
// Ensure DB has fresh data for this package before computing recent
if (!category) {
await ensurePackageFreshnessFor(packageName);
}
if (category && RECENT_CATEGORIES.includes(category)) {
// Compute recent from overall without mirrors
@@ -44,8 +56,12 @@ export async function getRecentDownloads(packageName: string, category?: string)
const month: Results[] = await getRecentDownloads(packageName, 'month');
const result: Results[] = [...day, ...week, ...month];
// Cache the result for 1 hour
await cache.set(cacheKey, result, 3600);
// Cache only if non-empty; otherwise clear any stale empty cache
if (result.length > 0) {
await cache.set(cacheKey, result, 3600);
} else {
await cache.del(cacheKey);
}
return result;
}
@@ -66,11 +82,8 @@ function getRecentBounds(category: string) {
export async function getOverallDownloads(packageName: string, mirrors?: string) {
const cacheKey = CacheManager.getPackageKey(packageName, `overall_${mirrors || 'all'}`);
// Try to get from cache first
const cached = await cache.get<Results[]>(cacheKey);
if (cached) {
return cached;
}
// Always ensure DB freshness first to avoid returning stale cache
await ensurePackageFreshnessFor(packageName);
const whereClause: any = {
package: packageName
@@ -89,8 +102,12 @@ export async function getOverallDownloads(packageName: string, mirrors?: string)
}
});
// Cache the result for 1 hour
await cache.set(cacheKey, result, 3600);
// Cache only if non-empty; otherwise clear any stale empty cache
if (result.length > 0) {
await cache.set(cacheKey, result, 3600);
} else {
await cache.del(cacheKey);
}
return result;
}
@@ -98,11 +115,8 @@ export async function getOverallDownloads(packageName: string, mirrors?: string)
export async function getPythonMajorDownloads(packageName: string, version?: string) {
const cacheKey = CacheManager.getPackageKey(packageName, `python_major_${version || 'all'}`);
// Try to get from cache first
const cached = await cache.get<Results[]>(cacheKey);
if (cached) {
return cached;
}
// Ensure DB freshness first
await ensurePackageFreshnessFor(packageName);
const whereClause: any = {
package: packageName
@@ -119,8 +133,11 @@ export async function getPythonMajorDownloads(packageName: string, version?: str
}
});
// Cache the result for 1 hour
await cache.set(cacheKey, result, 3600);
if (result.length > 0) {
await cache.set(cacheKey, result, 3600);
} else {
await cache.del(cacheKey);
}
return result;
}
@@ -128,11 +145,8 @@ export async function getPythonMajorDownloads(packageName: string, version?: str
export async function getPythonMinorDownloads(packageName: string, version?: string) {
const cacheKey = CacheManager.getPackageKey(packageName, `python_minor_${version || 'all'}`);
// Try to get from cache first
const cached = await cache.get<Results[]>(cacheKey);
if (cached) {
return cached;
}
// Ensure DB freshness first
await ensurePackageFreshnessFor(packageName);
const whereClause: any = {
package: packageName
@@ -149,8 +163,11 @@ export async function getPythonMinorDownloads(packageName: string, version?: str
}
});
// Cache the result for 1 hour
await cache.set(cacheKey, result, 3600);
if (result.length > 0) {
await cache.set(cacheKey, result, 3600);
} else {
await cache.del(cacheKey);
}
return result;
}
@@ -158,11 +175,8 @@ export async function getPythonMinorDownloads(packageName: string, version?: str
export async function getSystemDownloads(packageName: string, os?: string) {
const cacheKey = CacheManager.getPackageKey(packageName, `system_${os || 'all'}`);
// Try to get from cache first
const cached = await cache.get<Results[]>(cacheKey);
if (cached) {
return cached;
}
// Ensure DB freshness first
await ensurePackageFreshnessFor(packageName);
const whereClause: any = {
package: packageName
@@ -179,68 +193,242 @@ export async function getSystemDownloads(packageName: string, os?: string) {
}
});
// Cache the result for 1 hour
await cache.set(cacheKey, result, 3600);
if (result.length > 0) {
await cache.set(cacheKey, result, 3600);
} else {
await cache.del(cacheKey);
}
return result;
}
export async function getInstallerDownloads(packageName: string, installer?: string) {
const cacheKey = CacheManager.getPackageKey(packageName, `installer_${installer || 'all'}`);
// Ensure DB freshness first
await ensurePackageFreshnessFor(packageName);
const whereClause: any = { package: packageName };
if (installer) whereClause.category = installer;
const result = await (prisma as any).installerDownloadCount.findMany({
where: whereClause,
orderBy: { date: 'asc' }
});
if (result.length > 0) {
await cache.set(cacheKey, result, 3600);
} else {
await cache.del(cacheKey);
}
return result as Array<{ date: Date; package: string; category: string; downloads: number }>;
}
export async function getVersionDownloads(packageName: string, version?: string) {
const cacheKey = CacheManager.getPackageKey(packageName, `version_${version || 'all'}`);
// Ensure DB freshness first
await ensurePackageFreshnessFor(packageName);
const whereClause: any = { package: packageName };
if (version) whereClause.category = version;
try {
const model = (prisma as any).versionDownloadCount;
if (!model) return [] as Array<{ date: Date; package: string; category: string; downloads: number }>;
const result = await model.findMany({
where: whereClause,
orderBy: { date: 'asc' }
});
if (result.length > 0) {
await cache.set(cacheKey, result, 3600);
} else {
await cache.del(cacheKey);
}
return result as Array<{ date: Date; package: string; category: string; downloads: number }>;
} catch (error) {
console.error('getVersionDownloads failed:', error);
return [] as Array<{ date: Date; package: string; category: string; downloads: number }>;
}
}
export async function searchPackages(searchTerm: string) {
const cacheKey = CacheManager.getSearchKey(searchTerm);
const query = (searchTerm || '').trim();
if (!query) return [] as string[];
const cacheKey = CacheManager.getSearchKey(query);
// Try to get from cache first
const cached = await cache.get<string[]>(cacheKey);
if (cached) {
return cached;
}
const results = await prisma.recentDownloadCount.findMany({
where: {
package: {
startsWith: searchTerm
},
category: 'month'
},
select: {
package: true
},
distinct: ['package'],
orderBy: {
package: 'asc'
},
take: 20
});
const packages = results.map(result => result.package);
// Use PyPI Simple API (PEP 691 JSON) to fetch the package index, cache it,
// then do a local prefix filter for suggestions.
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 8000);
const indexKey = CacheManager.getSearchKey('__simple_index__');
try {
// Try index from cache first
let allPackages = await cache.get<string[]>(indexKey);
// Cache the result for 30 minutes (search results change less frequently)
await cache.set(cacheKey, packages, 1800);
return packages;
if (!allPackages) {
const indexResponse = await fetch('https://pypi.org/simple/', {
method: 'GET',
headers: {
'Accept': 'application/vnd.pypi.simple.v1+json',
'User-Agent': 'pypistats.app (server-side)'
},
signal: controller.signal
});
if (!indexResponse.ok) {
console.error('PyPI Simple index error:', indexResponse.status, indexResponse.statusText);
} else {
const indexJson = (await indexResponse.json()) as { projects?: Array<{ name: string; url: string }>; };
allPackages = (indexJson.projects || []).map((p) => p.name);
if (allPackages.length > 0) {
// Cache the full index for 6 hours
await cache.set(indexKey, allPackages, 6 * 60 * 60);
}
}
}
const q = query.toLowerCase();
let matches: string[] = [];
if (Array.isArray(allPackages) && allPackages.length > 0) {
matches = allPackages
.filter((name) => name.toLowerCase().startsWith(q))
.slice(0, 20);
}
// Fallback: if no matches from the index, try exact project existence via JSON API
if (matches.length === 0) {
const projectResponse = await fetch(`https://pypi.org/pypi/${encodeURIComponent(query)}/json`, {
method: 'GET',
headers: {
'Accept': 'application/json',
'User-Agent': 'pypistats.app (server-side)'
},
signal: controller.signal
});
if (projectResponse.ok) {
matches = [query];
}
}
// Cache per-query matches for 30 minutes
await cache.set(cacheKey, matches, 1800);
return matches;
} catch (error) {
if ((error as any)?.name === 'AbortError') {
console.error('PyPI Simple API request timed out');
} else {
console.error('PyPI Simple/API request failed:', error);
}
return [] as string[];
} finally {
clearTimeout(timeout);
}
}
export async function getPackageCount() {
const cacheKey = CacheManager.getPackageCountKey();
// Try to get from cache first
const cached = await cache.get<number>(cacheKey);
if (cached !== null) {
return cached;
}
try {
// First try recent monthly snapshot as authoritative
const recent = await prisma.recentDownloadCount.findMany({
where: { category: 'month' },
distinct: ['package'],
select: { package: true }
});
const result = await prisma.recentDownloadCount.groupBy({
const distinct = new Set<string>(recent.map((r) => r.package));
const count = distinct.size;
return count;
} catch (error) {
console.error('getPackageCount failed:', error);
return undefined
}
}
export async function getPopularPackages(limit = 10, days = 30): Promise<Array<{ package: string; downloads: number }>> {
const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
// Prefer 'without_mirrors' as the canonical signal
const grouped = await prisma.overallDownloadCount.groupBy({
by: ['package'],
where: {
category: 'month'
}
category: 'without_mirrors',
date: { gte: cutoff }
},
_sum: { downloads: true },
orderBy: { _sum: { downloads: 'desc' } },
take: limit
});
const count = result.length;
return grouped.map((g) => ({ package: g.package, downloads: Number(g._sum.downloads || 0) }));
}
// Cache the result for 1 hour
await cache.set(cacheKey, count, 3600);
return count;
export type PackageMetadata = {
name: string;
version: string | null;
summary: string | null;
homePage: string | null;
projectUrls: Record<string, string> | null;
pypiUrl: string;
latestReleaseDate: string | null;
};
export async function getPackageMetadata(packageName: string): Promise<PackageMetadata> {
const url = `https://pypi.org/pypi/${encodeURIComponent(packageName)}/json`;
try {
const res = await fetch(url, {
method: 'GET',
headers: {
'Accept': 'application/json',
'User-Agent': 'pypistats.app (server-side)'
}
});
if (!res.ok) {
return {
name: packageName,
version: null,
summary: null,
homePage: null,
projectUrls: null,
pypiUrl: `https://pypi.org/project/${packageName}/`,
latestReleaseDate: null
};
}
const json = await res.json();
const info = json?.info || {};
const version = info?.version ?? null;
// Determine latest upload time for the current version
let latestReleaseDate: string | null = null;
try {
const releases = json?.releases || {};
const files = Array.isArray(releases?.[version]) ? releases[version] : [];
const latest = files.reduce((max: string | null, f: any) => {
const t = f?.upload_time_iso_8601 || f?.upload_time || null;
if (!t) return max;
return !max || new Date(t).getTime() > new Date(max).getTime() ? t : max;
}, null as string | null);
latestReleaseDate = latest ? new Date(latest).toISOString().split('T')[0] : null;
} catch {}
return {
name: packageName,
version,
summary: info?.summary ?? null,
homePage: info?.home_page ?? null,
projectUrls: (info?.project_urls as Record<string, string> | undefined) ?? null,
pypiUrl: `https://pypi.org/project/${packageName}/`,
latestReleaseDate
};
} catch (error) {
console.error('getPackageMetadata error:', error);
return {
name: packageName,
version: null,
summary: null,
homePage: null,
projectUrls: null,
pypiUrl: `https://pypi.org/project/${packageName}/`,
latestReleaseDate: null
};
}
}
// Cache invalidation functions

View File

@@ -313,6 +313,7 @@ export class DataProcessor {
WITH dls AS (
SELECT
file.project AS package,
file.version AS file_version,
details.installer.name AS installer,
details.python AS python_version,
details.system.name AS system
@@ -374,6 +375,17 @@ export class DataProcessor {
FROM dls
WHERE installer NOT IN (${MIRRORS.map(m => `'${m}'`).join(', ')})
GROUP BY package, category
UNION ALL
SELECT
package,
'version' AS category_label,
COALESCE(file_version, 'unknown') AS category,
COUNT(*) AS downloads
FROM dls
WHERE installer NOT IN (${MIRRORS.map(m => `'${m}'`).join(', ')})
GROUP BY package, category
`;
}
@@ -446,6 +458,7 @@ export class DataProcessor {
SELECT
DATE(timestamp) AS date,
file.project AS package,
file.version AS file_version,
details.installer.name AS installer,
details.python AS python_version,
details.system.name AS system
@@ -518,6 +531,17 @@ export class DataProcessor {
COUNT(*) AS downloads
FROM dls
GROUP BY date, package, category
UNION ALL
SELECT
date,
package,
'version' AS category_label,
COALESCE(file_version, 'unknown') AS category,
COUNT(*) AS downloads
FROM dls
GROUP BY date, package, category
`;
}
@@ -550,15 +574,68 @@ export class DataProcessor {
where: { date: dateObj }
});
break;
case 'version':
if ((tx as any).versionDownloadCount) {
await (tx as any).versionDownloadCount.deleteMany({ where: { date: dateObj } });
}
break;
}
}
private async insertRecords(table: string, records: any[], tx: Prisma.TransactionClient): Promise<void> {
const normalizeDate = (value: any): Date => {
if (value instanceof Date) return value;
if (typeof value === 'string') {
const trimmed = value.trim();
if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) {
const d = new Date(`${trimmed}T00:00:00Z`);
if (!isNaN(d.getTime())) return d;
}
const d = new Date(trimmed);
if (!isNaN(d.getTime())) return d;
}
if (value && typeof value === 'object') {
// BigQuery DATE often arrives as { value: 'YYYY-MM-DD' }
if (typeof (value as any).value === 'string') {
return normalizeDate((value as any).value);
}
// Some drivers return { year, month, day }
const maybeY = (value as any).year;
const maybeM = (value as any).month;
const maybeD = (value as any).day;
if (
typeof maybeY === 'number' &&
typeof maybeM === 'number' &&
typeof maybeD === 'number'
) {
const mm = String(maybeM).padStart(2, '0');
const dd = String(maybeD).padStart(2, '0');
return normalizeDate(`${maybeY}-${mm}-${dd}`);
}
// Timestamp-like with toDate()
if (typeof (value as any).toDate === 'function') {
const d = (value as any).toDate();
if (d instanceof Date && !isNaN(d.getTime())) return d;
}
// Timestamp-like with seconds/nanos
if (
typeof (value as any).seconds === 'number' ||
typeof (value as any).nanos === 'number'
) {
const seconds = Number((value as any).seconds || 0);
const nanos = Number((value as any).nanos || 0);
const d = new Date(seconds * 1000 + Math.floor(nanos / 1e6));
if (!isNaN(d.getTime())) return d;
}
}
throw new Error(`Invalid date value: ${value}`);
};
switch (table) {
case 'overall':
await tx.overallDownloadCount.createMany({
data: records.map(r => ({
date: new Date(r.date),
date: normalizeDate(r.date),
package: r.package,
category: r.category ?? 'unknown',
downloads: r.downloads
@@ -568,7 +645,7 @@ export class DataProcessor {
case 'python_major':
await tx.pythonMajorDownloadCount.createMany({
data: records.map(r => ({
date: new Date(r.date),
date: normalizeDate(r.date),
package: r.package,
category: r.category ?? 'unknown',
downloads: r.downloads
@@ -578,7 +655,7 @@ export class DataProcessor {
case 'python_minor':
await tx.pythonMinorDownloadCount.createMany({
data: records.map(r => ({
date: new Date(r.date),
date: normalizeDate(r.date),
package: r.package,
category: r.category ?? 'unknown',
downloads: r.downloads
@@ -588,7 +665,7 @@ export class DataProcessor {
case 'system':
await tx.systemDownloadCount.createMany({
data: records.map(r => ({
date: new Date(r.date),
date: normalizeDate(r.date),
package: r.package,
category: r.category ?? 'other',
downloads: r.downloads
@@ -598,13 +675,25 @@ export class DataProcessor {
case 'installer':
await (tx as any).installerDownloadCount.createMany({
data: records.map(r => ({
date: new Date(r.date),
date: normalizeDate(r.date),
package: r.package,
category: r.category ?? 'unknown',
downloads: r.downloads
}))
});
break;
case 'version':
if ((tx as any).versionDownloadCount) {
await (tx as any).versionDownloadCount.createMany({
data: records.map(r => ({
date: normalizeDate(r.date),
package: r.package,
category: r.category ?? 'unknown',
downloads: r.downloads
}))
});
}
break;
}
}
@@ -735,6 +824,11 @@ export class DataProcessor {
where: { date: { lt: purgeDate } }
});
return systemResult.count;
case 'version':
const versionResult = await (prisma as any).versionDownloadCount.deleteMany({
where: { date: { lt: purgeDate } }
});
return versionResult.count;
default:
return 0;
}

View File

@@ -26,9 +26,7 @@
<a href="/api" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
API
</a>
<a href="/admin" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
Admin
</a>
</div>
</div>
</div>

View File

@@ -1,15 +1,23 @@
import { getPackageCount } from '$lib/api.js';
import { getPackageCount, getPopularPackages } from '$lib/api.js';
import type { PageServerLoad } from './$types';
export const load = async () => {
export const load: PageServerLoad = async () => {
try {
const packageCount = getPackageCount();
// Count distinct packages that have any saved data in DB (recent/month as proxy)
const [packageCount, popular] = await Promise.all([
getPackageCount(),
getPopularPackages(10, 30)
]);
return {
packageCount
packageCount,
popular
};
} catch (error) {
console.error('Error loading page data:', error);
return {
packageCount: 0
packageCount: 0,
popular: []
};
}
};
};

View File

@@ -1,5 +1,4 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { PageData } from './$types';
const { data } = $props<{ data: PageData }>();
@@ -22,8 +21,8 @@
</p>
<!-- Search Form -->
<div class="max-w-md mx-auto">
<form method="POST" action="/search" use:enhance class="flex gap-2">
<div class="max-w-md mx-auto">
<form method="GET" action="/search" class="flex gap-2">
<input
type="text"
name="q"
@@ -41,20 +40,29 @@
</form>
</div>
{#await data.packageCount then packageCount}
<div class="mt-8 text-sm text-gray-500">
Tracking {packageCount?.toLocaleString()} packages
</div>
{/await}
<div class="mt-8 text-sm text-gray-500">
Tracking {data.packageCount ? data.packageCount.toLocaleString() : 'tons of'} packages
</div>
</div>
<!-- Quick Links -->
<div class="mt-16 grid grid-cols-1 md:grid-cols-3 gap-8">
<div class="bg-white p-6 rounded-lg shadow-sm border">
<h3 class="text-lg font-semibold text-gray-900 mb-2">Popular Packages</h3>
<p class="text-gray-600 mb-4">Check download stats for popular Python packages</p>
<a href="/search/numpy" class="text-blue-600 hover:text-blue-800 font-medium">View Examples →</a>
</div>
<div class="bg-white p-6 rounded-lg shadow-sm border">
<h3 class="text-lg font-semibold text-gray-900 mb-2">Popular Packages (last 30 days)</h3>
<p class="text-gray-600 mb-4">Top projects by downloads (without mirrors)</p>
{#if data.popular && data.popular.length > 0}
<ul class="divide-y divide-gray-200">
{#each data.popular as row}
<li class="py-2 flex items-center justify-between">
<a class="text-blue-600 hover:text-blue-800 font-medium" href="/packages/{row.package}" data-sveltekit-preload-data="off">{row.package}</a>
<span class="text-sm text-gray-500">{row.downloads.toLocaleString()}</span>
</li>
{/each}
</ul>
{:else}
<div class="text-sm text-gray-500">No data yet.</div>
{/if}
</div>
<div class="bg-white p-6 rounded-lg shadow-sm border">
<h3 class="text-lg font-semibold text-gray-900 mb-2">API Access</h3>

View File

@@ -153,8 +153,9 @@
<div class="min-h-screen bg-gray-50 py-8">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900">Admin Dashboard</h1>
<p class="mt-2 text-gray-600">Manage data processing and cache operations</p>
<h1 class="text-3xl font-bold text-gray-900">Not Found</h1>
<p class="mt-2 text-gray-600">The admin dashboard has been removed.</p>
<p class="mt-4"><a href="/" class="text-blue-600 hover:text-blue-800">Return to homepage</a></p>
</div>
{#if error}

View File

@@ -0,0 +1,154 @@
import type { RequestHandler } from './$types';
import { dev } from '$app/environment';
import { CacheManager } from '$lib/redis.js';
import { getOverallDownloads, getPythonMajorDownloads, getPythonMinorDownloads, getSystemDownloads, getInstallerDownloads, getVersionDownloads } from '$lib/api.js';
import { ChartJSNodeCanvas } from 'chartjs-node-canvas';
const cache = new CacheManager();
const width = 1200;
const height = 600;
export const GET: RequestHandler = async ({ params, url }) => {
const packageName = params.package?.replace(/\./g, '-').replace(/_/g, '-') || '';
const type = params.type || 'overall';
const chartType = (url.searchParams.get('chart') || 'line').toLowerCase(); // 'line' | 'bar'
const mirrors = url.searchParams.get('mirrors') || undefined; // for overall
const version = url.searchParams.get('version') || undefined; // for python_* filters
const os = url.searchParams.get('os') || undefined; // for system filter
const format = (url.searchParams.get('format') || '').toLowerCase(); // 'json' to return data only
if (!packageName || packageName === '__all__') {
return new Response('Invalid package', { status: 400 });
}
const cacheKey = `pypistats:chart:${packageName}:${type}:${chartType}:${mirrors || ''}:${version || ''}:${os || ''}`;
const skipCache = dev || url.searchParams.get('nocache') === '1' || url.searchParams.get('cache') === 'false';
if (!skipCache) {
const cached = await cache.get<string>(cacheKey);
if (cached) {
const buffer = Buffer.from(cached, 'base64');
return new Response(buffer, { headers: { 'Content-Type': 'image/png', 'Cache-Control': 'public, max-age=3600' } });
}
}
try {
// Fetch data based on type
let rows: Array<{ date: any; category: string; downloads: number | bigint }>;
if (type === 'overall') {
rows = await getOverallDownloads(packageName, mirrors);
} else if (type === 'python_major') {
rows = await getPythonMajorDownloads(packageName, version);
} else if (type === 'python_minor') {
rows = await getPythonMinorDownloads(packageName, version);
} else if (type === 'system') {
rows = await getSystemDownloads(packageName, os);
} else if (type === 'installer') {
rows = await getInstallerDownloads(packageName);
} else if (type === 'version') {
rows = await getVersionDownloads(packageName);
} else {
return new Response('Unknown chart type', { status: 400 });
}
// Group by category -> timeseries
const seriesMap = new Map<string, Array<{ x: string; y: number }>>();
for (const r of rows) {
const key = r.category;
if (!seriesMap.has(key)) seriesMap.set(key, []);
const isoDate = typeof r.date === 'string' ? r.date : new Date(r.date).toISOString().split('T')[0];
seriesMap.get(key)!.push({ x: isoDate, y: Number(r.downloads) });
}
for (const arr of seriesMap.values()) {
arr.sort((a, b) => a.x.localeCompare(b.x));
}
// Build normalized date labels so datasets align with labels
const labels = Array.from(
new Set(
rows.map(r => (typeof r.date === 'string' ? r.date : new Date(r.date).toISOString().split('T')[0]))
)
).sort();
const datasets = Array.from(seriesMap.entries()).map(([label, points], idx) => {
const color = palette[idx % palette.length];
return {
label,
data: labels.map(l => points.find(p => p.x === l)?.y ?? 0),
borderColor: color,
backgroundColor: color + '33',
fill: chartType === 'line'
} as any;
});
// Human-friendly chart title
const typeTitle =
type === 'overall' ? 'Overall downloads (with/without mirrors)'
: type === 'python_major' ? 'Downloads by Python major version'
: type === 'python_minor' ? 'Downloads by Python minor version'
: type === 'system' ? 'Downloads by operating system'
: type === 'installer' ? 'Downloads by installer'
: type === 'version' ? 'Downloads by version'
: `${type} downloads`;
const titleText = `${packageName}${typeTitle}`;
// If JSON/data requested, return data for interactive charts
if (format === 'json' || format === 'data') {
const body = {
package: packageName,
type,
chartType,
title: titleText,
labels,
datasets
};
return new Response(JSON.stringify(body), { headers: { 'Content-Type': 'application/json', 'Cache-Control': 'public, max-age=300' } });
}
const configuration = {
type: chartType as any,
data: { labels, datasets },
options: {
responsive: false,
plugins: {
legend: { position: 'bottom' },
title: { display: true, text: titleText }
},
scales: {
x: {
title: { display: true, text: 'Date' },
ticks: { autoSkip: true, maxTicksLimit: 12 }
},
y: {
title: { display: true, text: 'Downloads' },
beginAtZero: true,
ticks: {
callback: (value: any) => {
try { return Number(value).toLocaleString(); } catch { return value; }
}
}
}
}
}
};
const renderer = new ChartJSNodeCanvas({ width, height, backgroundColour: 'white' });
const image = await renderer.renderToBuffer(configuration as any, 'image/png');
// Cache for 1 hour (disabled in dev or when nocache is set)
if (!skipCache) {
await cache.set(cacheKey, image.toString('base64'), 3600);
}
return new Response(image, { headers: { 'Content-Type': 'image/png', 'Cache-Control': 'public, max-age=3600' } });
} catch (error) {
console.error('Error rendering chart:', error);
return new Response('Internal server error', { status: 500 });
}
};
const palette = [
'#2563eb', '#16a34a', '#dc2626', '#7c3aed', '#ea580c', '#0891b2', '#ca8a04', '#4b5563'
];

View File

@@ -0,0 +1,60 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { prisma } from '$lib/prisma.js';
import { DataProcessor } from '$lib/data-processor.js';
export const GET: RequestHandler = async ({ params }) => {
const packageName = params.package?.replace(/\./g, '-').replace(/_/g, '-') || '';
if (!packageName || packageName === '__all__') {
return json({ error: 'Invalid package name' }, { status: 400 });
}
try {
const processor = new DataProcessor();
await processor.ensurePackageFreshness(packageName);
const [overallAll, systemAll, pyMajorAll, pyMinorAll] = await Promise.all([
prisma.overallDownloadCount.groupBy({
by: ['category'],
where: { package: packageName },
_sum: { downloads: true }
}),
prisma.systemDownloadCount.groupBy({
by: ['category'],
where: { package: packageName },
_sum: { downloads: true }
}),
prisma.pythonMajorDownloadCount.groupBy({
by: ['category'],
where: { package: packageName },
_sum: { downloads: true }
}),
prisma.pythonMinorDownloadCount.groupBy({
by: ['category'],
where: { package: packageName },
_sum: { downloads: true }
})
]);
const overallTotal = overallAll.reduce((sum, r) => sum + Number(r._sum.downloads || 0), 0);
const systemTotals = Object.fromEntries(systemAll.map(r => [r.category, Number(r._sum.downloads || 0)]));
const pythonMajorTotals = Object.fromEntries(pyMajorAll.map(r => [r.category, Number(r._sum.downloads || 0)]));
const pythonMinorTotals = Object.fromEntries(pyMinorAll.map(r => [r.category, Number(r._sum.downloads || 0)]));
return json({
package: packageName,
type: 'summary',
totals: {
overall: overallTotal,
system: systemTotals,
python_major: pythonMajorTotals,
python_minor: pythonMinorTotals
}
});
} catch (error) {
console.error('Error building package summary:', error);
return json({ error: 'Internal server error' }, { status: 500 });
}
};

View File

@@ -0,0 +1,89 @@
import {
getRecentDownloads,
getOverallDownloads,
getPythonMajorDownloads,
getPythonMinorDownloads,
getSystemDownloads,
getPackageMetadata,
getInstallerDownloads,
getVersionDownloads
} from '$lib/api.js';
import type { PageServerLoad } from './$types';
// Streaming responses: use native promises in returned object (SvelteKit will stream)
export const load: PageServerLoad = async ({ params }) => {
const packageName = params.package?.replace(/\./g, '-').replace(/_/g, '-') || '';
if (!packageName || packageName === '__all__') {
return {
packageName,
recentStats: null,
overallStats: [],
pythonMajorStats: [],
pythonMinorStats: [],
systemStats: []
};
}
try {
const recentStatsP = getRecentDownloads(packageName).then((recent) => {
const fm: Record<string, number> = {};
for (const stat of recent) fm[`last_${stat.category}`] = Number(stat.downloads);
return fm;
});
const overallStatsP = getOverallDownloads(packageName);
const pythonMajorStatsP = getPythonMajorDownloads(packageName);
const pythonMinorStatsP = getPythonMinorDownloads(packageName);
const systemStatsP = getSystemDownloads(packageName);
const installerStatsP = getInstallerDownloads(packageName);
const versionStatsP = getVersionDownloads(packageName);
const metaP = getPackageMetadata(packageName);
const summaryTotalsP = Promise.all([
overallStatsP,
pythonMajorStatsP,
pythonMinorStatsP,
systemStatsP,
installerStatsP,
versionStatsP
]).then(([overall, pyMaj, pyMin, system, installer, version]) => {
const sum = <T extends { category: string; downloads: any }>(rows: T[]) => {
const map: Record<string, number> = {};
for (const r of rows) map[r.category] = (map[r.category] || 0) + Number(r.downloads);
return map;
};
return {
overall: sum(overall),
python_major: sum(pyMaj),
python_minor: sum(pyMin),
system: sum(system),
installer: sum(installer),
version: sum(version)
}
});
return {
packageName,
meta: metaP,
recentStats: recentStatsP,
overallStats: overallStatsP,
pythonMajorStats: pythonMajorStatsP,
pythonMinorStats: pythonMinorStatsP,
systemStats: systemStatsP,
summaryTotals: summaryTotalsP
};
} catch (error) {
console.error('Error loading package data:', error);
return {
packageName,
recentStats: null,
overallStats: [],
pythonMajorStats: [],
pythonMinorStats: [],
systemStats: []
};
}
};

View File

@@ -1,155 +1,549 @@
<script lang="ts">
import type { PageData } from './$types';
const { data } = $props<{ data: PageData }>();
import type { PageData } from './$types';
import { onMount, onDestroy } from 'svelte';
const { data }: { data: PageData } = $props();
let overallCanvas: HTMLCanvasElement | null = $state(null);
let pyMajorCanvas: HTMLCanvasElement | null = $state(null);
let pyMinorCanvas: HTMLCanvasElement | null = $state(null);
let systemCanvas: HTMLCanvasElement | null = $state(null);
let charts: any[] = [];
let installerCanvas: HTMLCanvasElement | null = $state(null);
const numberFormatter = new Intl.NumberFormat(undefined);
const compactFormatter = new Intl.NumberFormat(undefined, {
notation: 'compact',
maximumFractionDigits: 1
});
function formatNumber(n: number) {
try {
return numberFormatter.format(n);
} catch {
return String(n);
}
}
function formatCompact(n: number) {
try {
return compactFormatter.format(n);
} catch {
return String(n);
}
}
// Streaming handled with {#await} blocks below
// Build combined Python versions rows reactively
type PythonRow = { kind: 'major' | 'minor'; label: string; downloads: number };
function buildPythonVersionRows(
pyMajorTotalsMap: Record<string, number>,
pyMinorTotalsMap: Record<string, number>
): PythonRow[] {
const rows: PythonRow[] = [];
const majorOrder = ['2', '3', 'unknown'];
for (const major of majorOrder) {
const majorDownloads = Number(pyMajorTotalsMap[major] || 0);
if (majorDownloads > 0 || major in pyMajorTotalsMap) {
rows.push({ kind: 'major', label: major, downloads: majorDownloads });
const minors: Array<[string, number]> = Object.entries(pyMinorTotalsMap)
.filter(([k]) => (major === 'unknown' ? k === 'unknown' : k.startsWith(major + '.')))
.map(([k, v]) => [k, Number(v)]);
minors.sort((a, b) => {
const ak =
a[0] === 'unknown' ? Number.POSITIVE_INFINITY : parseFloat(a[0].split('.')[1] || '0');
const bk =
b[0] === 'unknown' ? Number.POSITIVE_INFINITY : parseFloat(b[0].split('.')[1] || '0');
return ak - bk;
});
for (const [minor, dls] of minors)
rows.push({ kind: 'minor', label: minor, downloads: dls });
}
}
return rows;
}
async function loadAndRenderChart(
canvas: HTMLCanvasElement | null,
type: string,
params: Record<string, string> = {}
) {
if (!canvas) return;
const qs = new URLSearchParams({ format: 'json', chart: 'line', ...params });
const resp = await fetch(
`/api/packages/${encodeURIComponent(data.packageName)}/chart/${type}?${qs.toString()}`
);
if (!resp.ok) return;
const payload = await resp.json();
const { default: Chart } = await import('chart.js/auto');
const ctx = canvas.getContext('2d');
if (!ctx) return;
const chart = new Chart(ctx, {
type: (payload.chartType || 'line') as any,
data: { labels: payload.labels, datasets: payload.datasets },
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { position: 'bottom' },
title: { display: true, text: payload.title },
tooltip: {
callbacks: {
label: (ctx: any) =>
`${ctx.dataset.label}: ${formatNumber(Number(ctx.parsed?.y ?? ctx.raw?.y ?? 0))}`
}
}
},
scales: {
x: {
title: { display: true, text: 'Date' },
ticks: {
autoSkip: true,
maxTicksLimit: 8,
callback: (value: any, index: number, ticks: any[]) => {
const label = (payload.labels?.[index] ?? '').toString();
// Show MM/dd for compactness
const d = new Date(label);
if (!isNaN(d.getTime())) return `${d.getMonth() + 1}/${d.getDate()}`;
return label;
}
}
},
y: {
title: { display: true, text: 'Downloads' },
beginAtZero: true,
ticks: {
callback: (value: any) => formatCompact(Number(value))
}
}
}
}
});
charts.push(chart);
}
onMount(() => {
loadAndRenderChart(overallCanvas, 'overall');
requestAnimationFrame(() => {
loadAndRenderChart(pyMajorCanvas, 'python_major');
loadAndRenderChart(pyMinorCanvas, 'python_minor');
loadAndRenderChart(systemCanvas, 'system');
loadAndRenderChart(installerCanvas, 'installer');
});
});
onDestroy(() => {
for (const c of charts) {
try {
c.destroy();
} catch {}
}
charts = [];
});
</script>
<svelte:head>
<title>{data.packageName} - PyPI Stats</title>
<meta name="description" content="Download statistics for {data.packageName} package" />
<title>{data.packageName} - PyPI Stats</title>
<meta name="description" content="Download statistics for {data.packageName} package" />
</svelte:head>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div class="mx-auto max-w-7xl px-4 py-12 sm:px-6 lg:px-8">
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900 mb-2">{data.packageName}</h1>
<h1 class="mb-2 text-3xl font-bold text-gray-900">{data.packageName}</h1>
<p class="text-gray-600">Download statistics from PyPI</p>
{#if data.meta}
{#await data.meta}
<div class="mt-4 flex flex-wrap items-center gap-2 text-sm">
<span
class="inline-flex items-center rounded-full border bg-gray-50 px-2.5 py-1 text-gray-700"
>Loading…</span
>
</div>
{:then meta}
<div class="mt-4 flex flex-wrap items-center gap-2 text-sm">
{#if meta.version}
<span
class="inline-flex items-center rounded-full border border-blue-200 bg-blue-50 px-2.5 py-1 text-blue-700"
>v{meta.version}</span
>
{/if}
{#if meta.latestReleaseDate}
<span
class="inline-flex items-center rounded-full border border-green-200 bg-green-50 px-2.5 py-1 text-green-700"
>Released {meta.latestReleaseDate}</span
>
{/if}
</div>
<div class="mt-4 flex flex-wrap items-center gap-2 text-sm">
{#if data.summaryTotals}
{#await data.summaryTotals then totals}
{#if totals?.system}
{#await Promise.resolve(Object.entries(totals.system).sort((a, b) => Number(b[1]) - Number(a[1]))[0]) then topSys}
{#if topSys}
<span
class="inline-flex items-center rounded-full border border-purple-200 bg-purple-50 px-2.5 py-1 text-purple-700"
>Popular system: {topSys[0]}</span
>
{/if}
{/await}
{/if}
{/await}
{/if}
{#if data.summaryTotals}
{#await data.summaryTotals then totals}
{#if totals?.installer}
{#await Promise.resolve(Object.entries(totals.installer).sort((a, b) => Number(b[1]) - Number(a[1]))[0]) then topInst}
{#if topInst}
<span
class="inline-flex items-center rounded-full border border-indigo-200 bg-indigo-50 px-2.5 py-1 text-indigo-700"
>Popular installer: {topInst[0]}</span
>
{/if}
{/await}
{/if}
{/await}
{/if}
{#if data.summaryTotals}
{#await data.summaryTotals then totals}
{#if totals?.version}
{#await Promise.resolve(Object.entries(totals.version).sort((a, b) => Number(b[1]) - Number(a[1]))[0]) then topVer}
{#if topVer}
<span
class="inline-flex items-center rounded-full border border-amber-200 bg-amber-50 px-2.5 py-1 text-amber-700"
>Top version: {topVer[0]}</span
>
{/if}
{/await}
{/if}
{/await}
{/if}
</div>
<div class="mt-4 flex flex-wrap items-center gap-2 text-sm">
<a
class="inline-flex items-center rounded-full border bg-gray-50 px-2.5 py-1 text-gray-700 hover:bg-gray-100"
href={meta.pypiUrl}
rel="noopener"
target="_blank">View on PyPI</a
>
{#if meta.homePage}
<a
class="inline-flex items-center rounded-full border bg-gray-50 px-2.5 py-1 text-gray-700 hover:bg-gray-100"
href={meta.homePage}
rel="noopener"
target="_blank">Homepage</a
>
{/if}
{#if meta.projectUrls}
{#each Object.entries(meta.projectUrls).filter(([label, url]) => !['homepage'].includes(label.toLowerCase())) as [label, url]}
{#if typeof url === 'string'}
<a
class="inline-flex items-center rounded-full border bg-gray-50 px-2.5 py-1 text-gray-700 hover:bg-gray-100"
href={url}
rel="noopener"
target="_blank">{label}</a
>
{/if}
{/each}
{/if}
</div>
{/await}
{/if}
</div>
<!-- Recent Stats -->
{#if data.recentStats}
<div class="bg-white rounded-lg shadow-sm border mb-8">
<div class="px-6 py-4 border-b">
<!-- Recent Stats + Consolidated Totals -->
{#if data.recentStats}
<div class="mb-8 rounded-lg border bg-white shadow-sm">
<div class="border-b px-6 py-4">
<h2 class="text-lg font-semibold text-gray-900">Recent Downloads</h2>
</div>
<div class="p-6">
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
{#each Object.entries(data.recentStats) as [period, count]}
<div class="text-center">
<div class="text-2xl font-bold text-blue-600">
{(count as number).toLocaleString()}
</div>
<div class="text-sm text-gray-500 capitalize">
{period.replace('last_', '')}
<div class="flex flex-wrap gap-6">
<div class="max-w-full min-w-[280px] flex-1 grow">
<h4 class="mb-2 text-sm font-semibold text-gray-700">Recent</h4>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead>
<tr>
<th
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
>Period</th
>
<th
class="px-6 py-3 text-right text-xs font-medium tracking-wider text-gray-500 uppercase"
>Downloads</th
>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white">
{#await data.recentStats}
<tr><td class="px-6 py-3 text-sm text-gray-500" colspan="2">Loading…</td></tr>
{:then rs}
{#each [['week', Number((rs as any)?.last_week || 0)], ['month', Number((rs as any)?.last_month || 0)]] as [period, count]}
<tr>
<td class="px-6 py-3 text-sm text-gray-700 capitalize">{period}</td>
<td class="px-6 py-3 text-right text-sm font-semibold text-gray-900"
>{formatNumber(Number(count))}</td
>
</tr>
{/each}
{:catch _}
<tr
><td class="px-6 py-3 text-sm text-red-600" colspan="2">Failed to load</td
></tr
>
{/await}
</tbody>
</table>
</div>
</div>
<div class="max-w-full min-w-[280px] flex-1 grow">
<h4 class="mb-2 text-sm font-semibold text-gray-700">Overall</h4>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead>
<tr>
<th
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
>Category</th
>
<th
class="px-6 py-3 text-right text-xs font-medium tracking-wider text-gray-500 uppercase"
>Downloads</th
>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white">
{#await (data as any).summaryTotals}
<tr><td class="px-6 py-3 text-sm text-gray-500" colspan="2">Loading…</td></tr>
{:then totals}
{#each Object.entries(totals?.overall || {}).sort((a, b) => Number(b[1]) - Number(a[1])) as [k, v]}
<tr>
<td class="px-6 py-3 text-sm text-gray-700 capitalize"
>{k.replace(/_/g, ' ')}</td
>
<td class="px-6 py-3 text-right text-sm font-semibold text-gray-900"
>{formatNumber(Number(v))}</td
>
</tr>
{/each}
{:catch _}
<tr
><td class="px-6 py-3 text-sm text-red-600" colspan="2">Failed to load</td
></tr
>
{/await}
</tbody>
</table>
</div>
</div>
<div class="max-w-full min-w-[280px] flex-1 grow">
<h4 class="mb-2 text-sm font-semibold text-gray-700">Systems</h4>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead>
<tr>
<th
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
>System</th
>
<th
class="px-6 py-3 text-right text-xs font-medium tracking-wider text-gray-500 uppercase"
>Downloads</th
>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white">
{#await (data as any).summaryTotals}
<tr><td class="px-6 py-3 text-sm text-gray-500" colspan="2">Loading…</td></tr>
{:then totals}
{#each Object.entries(totals?.system || {}).sort((a, b) => Number(b[1]) - Number(a[1])) as [k, v]}
<tr>
<td class="px-6 py-3 text-sm text-gray-700 capitalize">{k}</td>
<td class="px-6 py-3 text-right text-sm font-semibold text-gray-900"
>{formatNumber(Number(v))}</td
>
</tr>
{/each}
{:catch _}
<tr
><td class="px-6 py-3 text-sm text-red-600" colspan="2">Failed to load</td
></tr
>
{/await}
</tbody>
</table>
</div>
</div>
</div>
<div class="mt-8">
<h3 class="text-md mb-2 font-semibold text-gray-900">Python Versions</h3>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead>
<tr>
<th
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
>Version</th
>
<th
class="px-6 py-3 text-right text-xs font-medium tracking-wider text-gray-500 uppercase"
>Downloads</th
>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white">
{#await (data as any).summaryTotals}
<tr><td class="px-6 py-3 text-sm text-gray-500" colspan="2">Loading…</td></tr>
{:then totals}
{#each buildPythonVersionRows(totals?.python_major || {}, totals?.python_minor || {}) as row}
<tr class={row.kind === 'major' ? 'bg-gray-50' : ''}>
<td class="px-6 py-3 text-sm text-gray-700 capitalize">
{#if row.kind === 'major'}
Python {row.label}
{:else}
<span class="inline-block pl-6">{row.label}</span>
{/if}
</td>
<td class="px-6 py-3 text-right text-sm font-semibold text-gray-900"
>{formatNumber(row.downloads)}</td
>
</tr>
{/each}
{:catch _}
<tr><td class="px-6 py-3 text-sm text-red-600" colspan="2">Failed to load</td></tr
>
{/await}
</tbody>
</table>
</div>
</div>
</div>
</div>
{/if}
<!-- Charts -->
{#if data.packageName}
<div class="mb-8 rounded-lg border bg-white shadow-sm">
<div class="border-b px-6 py-4">
<h2 class="text-lg font-semibold text-gray-900">Overall Downloads Over Time</h2>
<p class="text-sm text-gray-500">Includes with and without mirrors</p>
</div>
<div class="p-6">
<div class="w-full overflow-x-auto">
<div class="relative h-96 w-full">
<canvas bind:this={overallCanvas}></canvas>
</div>
</div>
</div>
</div>
{#await data.pythonMajorStats}
<!-- loading placeholder omitted to reduce layout shift -->
{:then majorArr}
{#if majorArr && majorArr.length > 0}
<div class="mb-8 rounded-lg border bg-white shadow-sm">
<div class="border-b px-6 py-4">
<h2 class="text-lg font-semibold text-gray-900">Python Major Versions</h2>
</div>
<div class="p-6">
<div class="w-full overflow-x-auto">
<div class="relative h-96 w-full">
<canvas bind:this={pyMajorCanvas}></canvas>
</div>
</div>
{/each}
</div>
</div>
</div>
</div>
{/if}
<!-- Overall Stats -->
{#if data.overallStats && data.overallStats.length > 0}
<div class="bg-white rounded-lg shadow-sm border mb-8">
<div class="px-6 py-4 border-b">
<h2 class="text-lg font-semibold text-gray-900">Overall Downloads</h2>
</div>
<div class="p-6">
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead>
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Date</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Category</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Downloads</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
{#each data.overallStats.slice(0, 10) as stat}
<tr>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{stat.date}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{stat.category}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{stat.downloads.toLocaleString()}</td>
</tr>
{/each}
</tbody>
</table>
{/if}
{/await}
{#await data.pythonMinorStats}
<!-- waiting -->
{:then minorArr}
{#if minorArr && minorArr.length > 0}
<div class="mb-8 rounded-lg border bg-white shadow-sm">
<div class="border-b px-6 py-4">
<h2 class="text-lg font-semibold text-gray-900">Python Minor Versions</h2>
</div>
<div class="p-6">
<div class="w-full overflow-x-auto">
<div class="relative h-96 w-full">
<canvas bind:this={pyMinorCanvas}></canvas>
</div>
</div>
</div>
</div>
</div>
</div>
{/if}
<!-- Python Version Stats -->
{#if data.pythonMajorStats && data.pythonMajorStats.length > 0}
<div class="bg-white rounded-lg shadow-sm border mb-8">
<div class="px-6 py-4 border-b">
<h2 class="text-lg font-semibold text-gray-900">Python Major Version Downloads</h2>
</div>
<div class="p-6">
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead>
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Date</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Version</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Downloads</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
{#each data.pythonMajorStats.slice(0, 10) as stat}
<tr>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{stat.date}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{stat.category}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{stat.downloads.toLocaleString()}</td>
</tr>
{/each}
</tbody>
</table>
{/if}
{/await}
{#await data.systemStats}
<!-- waiting -->
{:then sysArr}
{#if sysArr && sysArr.length > 0}
<div class="mb-8 rounded-lg border bg-white shadow-sm">
<div class="border-b px-6 py-4">
<h2 class="text-lg font-semibold text-gray-900">System Downloads</h2>
</div>
<div class="p-6">
<div class="w-full overflow-x-auto">
<div class="relative h-96 w-full">
<canvas bind:this={systemCanvas}></canvas>
</div>
</div>
</div>
</div>
</div>
</div>
{/if}
<!-- System Stats -->
{#if data.systemStats && data.systemStats.length > 0}
<div class="bg-white rounded-lg shadow-sm border mb-8">
<div class="px-6 py-4 border-b">
<h2 class="text-lg font-semibold text-gray-900">System Downloads</h2>
</div>
<div class="p-6">
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead>
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Date</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">System</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Downloads</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
{#each data.systemStats.slice(0, 10) as stat}
<tr>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{stat.date}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{stat.category}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{stat.downloads.toLocaleString()}</td>
</tr>
{/each}
</tbody>
</table>
{/if}
{/await}
{#await (data as any).summaryTotals}
<!-- waiting -->
{:then totals}
{#if totals && totals.installer}
<div class="mb-8 rounded-lg border bg-white shadow-sm">
<div class="border-b px-6 py-4">
<h2 class="text-lg font-semibold text-gray-900">Installer Breakdown</h2>
</div>
<div class="p-6">
<div class="relative h-96 w-full">
<canvas bind:this={installerCanvas}></canvas>
</div>
</div>
</div>
</div>
</div>
{/if}
{/await}
{/if}
<!-- API Links -->
<div class="bg-blue-50 rounded-lg p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4">API Access</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
<div class="rounded-lg bg-blue-50 p-6">
<h3 class="mb-4 text-lg font-semibold text-gray-900">API Access</h3>
<div class="grid grid-cols-1 gap-4 text-sm md:grid-cols-2">
<div>
<strong>Recent downloads:</strong>
<a href="/api/packages/{data.packageName}/recent" class="text-blue-600 hover:text-blue-800 ml-2">JSON</a>
<a
href="/api/packages/{data.packageName}/recent"
class="ml-2 text-blue-600 hover:text-blue-800">JSON</a
>
</div>
<div>
<strong>Overall downloads:</strong>
<a href="/api/packages/{data.packageName}/overall" class="text-blue-600 hover:text-blue-800 ml-2">JSON</a>
<a
href="/api/packages/{data.packageName}/overall"
class="ml-2 text-blue-600 hover:text-blue-800">JSON</a
>
</div>
<div>
<strong>Python major versions:</strong>
<a href="/api/packages/{data.packageName}/python_major" class="text-blue-600 hover:text-blue-800 ml-2">JSON</a>
<a
href="/api/packages/{data.packageName}/python_major"
class="ml-2 text-blue-600 hover:text-blue-800">JSON</a
>
</div>
<div>
<strong>System downloads:</strong>
<a href="/api/packages/{data.packageName}/system" class="text-blue-600 hover:text-blue-800 ml-2">JSON</a>
<a
href="/api/packages/{data.packageName}/system"
class="ml-2 text-blue-600 hover:text-blue-800">JSON</a
>
</div>
</div>
</div>
</div>
</div>

View File

@@ -1,59 +0,0 @@
import {
getRecentDownloads,
getOverallDownloads,
getPythonMajorDownloads,
getPythonMinorDownloads,
getSystemDownloads
} from '$lib/api.js';
import type { PageLoad } from './$types';
export const load: PageLoad = async ({ params }) => {
const packageName = params.package?.replace(/\./g, '-').replace(/_/g, '-') || '';
if (!packageName || packageName === '__all__') {
return {
packageName,
recentStats: null,
overallStats: [],
pythonMajorStats: [],
pythonMinorStats: [],
systemStats: []
};
}
try {
// Fetch all statistics in parallel
const [recentStats, overallStats, pythonMajorStats, pythonMinorStats, systemStats] = await Promise.all([
getRecentDownloads(packageName),
getOverallDownloads(packageName),
getPythonMajorDownloads(packageName),
getPythonMinorDownloads(packageName),
getSystemDownloads(packageName)
]);
// Process recent stats into the expected format
const recentStatsFormatted: Record<string, number> = {};
for (const stat of recentStats) {
recentStatsFormatted[`last_${stat.category}`] = Number(stat.downloads);
}
return {
packageName,
recentStats: recentStatsFormatted,
overallStats,
pythonMajorStats,
pythonMinorStats,
systemStats
};
} catch (error) {
console.error('Error loading package data:', error);
return {
packageName,
recentStats: null,
overallStats: [],
pythonMajorStats: [],
pythonMinorStats: [],
systemStats: []
};
}
};

View File

@@ -1,7 +1,6 @@
import { searchPackages } from '$lib/api.js';
import type { PageLoad } from './$types';
export const load: PageLoad = async ({ url }) => {
export const load = async ({ url }) => {
const searchTerm = url.searchParams.get('q');
if (!searchTerm) {
@@ -25,30 +24,5 @@ export const load: PageLoad = async ({ url }) => {
};
}
};
export const actions = {
default: async ({ request }) => {
const formData = await request.formData();
const searchTerm = formData.get('q');
if (!searchTerm) {
return {
packages: [],
searchTerm: null
};
}
try {
const packages = await searchPackages(searchTerm.toString());
return {
packages,
searchTerm
};
} catch (error) {
console.error('Error searching packages:', error);
return {
packages: [],
searchTerm
};
}
}
};

View File

@@ -1,8 +1,7 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { PageData } from './$types';
const { data } = $props<{ data: PageData }>();
let searchTerm = $state('');
let searchTerm = $state(data.searchTerm ?? '');
</script>
<svelte:head>
@@ -14,7 +13,7 @@
<h1 class="text-3xl font-bold text-gray-900 mb-8">Search Packages</h1>
<!-- Search Form -->
<form method="GET" action="/search" use:enhance class="mb-8">
<form method="GET" action="/search" class="mb-8">
<div class="flex gap-2">
<input
type="text"
@@ -43,7 +42,7 @@
<div class="divide-y divide-gray-200">
{#each data.packages as pkg}
<div class="px-6 py-4 hover:bg-gray-50">
<a href="/packages/{pkg}" class="block">
<a href="/packages/{pkg}" class="block" data-sveltekit-preload-data="off">
<div class="text-lg font-medium text-blue-600 hover:text-blue-800">
{pkg}
</div>