Initial commit
This commit is contained in:
312
lib/api.ts
Normal file
312
lib/api.ts
Normal file
@@ -0,0 +1,312 @@
|
||||
// API client for Next.js API routes
|
||||
|
||||
const API_BASE_URL = '/api';
|
||||
|
||||
export interface ApiError {
|
||||
message: string;
|
||||
status: number;
|
||||
}
|
||||
|
||||
async function handleResponse<T>(response: Response): Promise<T> {
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: 'An error occurred' }));
|
||||
// Extract error message from various possible response formats
|
||||
const message = error.error || error.message || 'An error occurred';
|
||||
console.error('API Error:', { status: response.status, message, fullError: error });
|
||||
throw { message, status: response.status } as ApiError;
|
||||
}
|
||||
|
||||
// Handle empty responses
|
||||
const text = await response.text();
|
||||
return text ? JSON.parse(text) : ({} as T);
|
||||
}
|
||||
|
||||
// Auth API
|
||||
export const authApi = {
|
||||
async login(username: string, password: string) {
|
||||
const response = await fetch(`${API_BASE_URL}/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
return handleResponse<{ accessToken: string; refreshToken: string }>(response);
|
||||
},
|
||||
|
||||
async logout() {
|
||||
const response = await fetch(`${API_BASE_URL}/auth/logout`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
});
|
||||
return handleResponse<void>(response);
|
||||
},
|
||||
|
||||
async refresh() {
|
||||
const response = await fetch(`${API_BASE_URL}/auth/refresh`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
});
|
||||
return handleResponse<{ accessToken: string }>(response);
|
||||
},
|
||||
|
||||
async me() {
|
||||
const response = await fetch(`${API_BASE_URL}/auth/me`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
return handleResponse<{ username: string }>(response);
|
||||
},
|
||||
};
|
||||
|
||||
// Wishlist types
|
||||
export interface Wishlist {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string | null;
|
||||
preferences: string | null;
|
||||
imageUrl: string | null;
|
||||
isPublic: boolean;
|
||||
sortOrder: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface Item {
|
||||
id: string;
|
||||
wishlistId: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
price: number | null;
|
||||
currency: string;
|
||||
quantity: number;
|
||||
imageUrl: string | null;
|
||||
purchaseUrls: Array<{ label: string; url: string }> | null;
|
||||
isArchived: boolean;
|
||||
claimedByName: string | null;
|
||||
claimedByNote: string | null;
|
||||
claimedAt: string | null;
|
||||
isPurchased: boolean;
|
||||
sortOrder: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// Wishlists API
|
||||
export const wishlistsApi = {
|
||||
async getAll() {
|
||||
const response = await fetch(`${API_BASE_URL}/wishlists`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
const data = await handleResponse<{ success: boolean; wishlists: Wishlist[] }>(response);
|
||||
return data.wishlists;
|
||||
},
|
||||
|
||||
async getAllPublic() {
|
||||
const response = await fetch(`${API_BASE_URL}/public/wishlists`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
const data = await handleResponse<{ success: boolean; wishlists: Wishlist[] }>(response);
|
||||
return data.wishlists;
|
||||
},
|
||||
|
||||
async getOne(id: string) {
|
||||
const response = await fetch(`${API_BASE_URL}/wishlists/${id}`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
const result = await handleResponse<{ success: boolean; wishlist: Wishlist }>(response);
|
||||
return result.wishlist;
|
||||
},
|
||||
|
||||
async getBySlug(slug: string) {
|
||||
const response = await fetch(`${API_BASE_URL}/${slug}`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
const result = await handleResponse<{ success: boolean; wishlist: Wishlist }>(response);
|
||||
return result.wishlist;
|
||||
},
|
||||
|
||||
async create(data: Partial<Wishlist>) {
|
||||
const response = await fetch(`${API_BASE_URL}/wishlists`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
const result = await handleResponse<{ success: boolean; wishlist: Wishlist }>(response);
|
||||
return result.wishlist;
|
||||
},
|
||||
|
||||
async update(id: string, data: Partial<Wishlist>) {
|
||||
const response = await fetch(`${API_BASE_URL}/wishlists/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
const result = await handleResponse<{ success: boolean; wishlist: Wishlist }>(response);
|
||||
return result.wishlist;
|
||||
},
|
||||
|
||||
async delete(id: string) {
|
||||
const response = await fetch(`${API_BASE_URL}/wishlists/${id}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
});
|
||||
return handleResponse<void>(response);
|
||||
},
|
||||
|
||||
async reorder(id: string, newSortOrder: number) {
|
||||
const response = await fetch(`${API_BASE_URL}/wishlists/${id}/reorder`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ newSortOrder }),
|
||||
});
|
||||
const result = await handleResponse<{ success: boolean; wishlist: Wishlist }>(response);
|
||||
return result.wishlist;
|
||||
},
|
||||
};
|
||||
|
||||
// Items API
|
||||
export const itemsApi = {
|
||||
async getAll(wishlistId: string) {
|
||||
const response = await fetch(`${API_BASE_URL}/wishlists/${wishlistId}/items`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
const result = await handleResponse<{ success: boolean; items: Item[] }>(response);
|
||||
return result.items;
|
||||
},
|
||||
|
||||
async getOne(id: string) {
|
||||
const response = await fetch(`${API_BASE_URL}/items/${id}`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
return handleResponse<Item>(response);
|
||||
},
|
||||
|
||||
async create(wishlistId: string, data: Partial<Item>) {
|
||||
const response = await fetch(`${API_BASE_URL}/wishlists/${wishlistId}/items`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return handleResponse<Item>(response);
|
||||
},
|
||||
|
||||
async update(id: string, data: Partial<Item>) {
|
||||
const response = await fetch(`${API_BASE_URL}/items/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return handleResponse<Item>(response);
|
||||
},
|
||||
|
||||
async delete(id: string) {
|
||||
const response = await fetch(`${API_BASE_URL}/items/${id}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
});
|
||||
return handleResponse<void>(response);
|
||||
},
|
||||
|
||||
async reorder(id: string, newSortOrder: number) {
|
||||
const response = await fetch(`${API_BASE_URL}/items/${id}/reorder`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ newSortOrder }),
|
||||
});
|
||||
const result = await handleResponse<{ success: boolean; item: Item }>(response);
|
||||
return result.item;
|
||||
},
|
||||
};
|
||||
|
||||
// Claiming API (public)
|
||||
export const claimingApi = {
|
||||
async claim(itemId: string, name?: string, note?: string) {
|
||||
const response = await fetch(`${API_BASE_URL}/public/items/${itemId}/claim`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ name, note }),
|
||||
});
|
||||
return handleResponse<{ claimToken: string; message: string }>(response);
|
||||
},
|
||||
|
||||
async unclaim(itemId: string) {
|
||||
const response = await fetch(`${API_BASE_URL}/public/items/${itemId}/unclaim`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
});
|
||||
return handleResponse<{ success: boolean; message: string }>(response);
|
||||
},
|
||||
};
|
||||
|
||||
// Scraping API
|
||||
export interface ScrapedData {
|
||||
title?: string;
|
||||
description?: string;
|
||||
price?: number;
|
||||
currency?: string;
|
||||
imageUrl?: string;
|
||||
}
|
||||
|
||||
export const scrapingApi = {
|
||||
async scrapeUrl(url: string) {
|
||||
const response = await fetch(`${API_BASE_URL}/scrape`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ url }),
|
||||
});
|
||||
return handleResponse<ScrapedData>(response);
|
||||
},
|
||||
};
|
||||
|
||||
// Settings API
|
||||
export interface Settings {
|
||||
siteTitle: string;
|
||||
homepageSubtext: string;
|
||||
passwordLockEnabled?: boolean;
|
||||
passwordLock?: string;
|
||||
}
|
||||
|
||||
export const settingsApi = {
|
||||
async getSettings() {
|
||||
const response = await fetch(`${API_BASE_URL}/settings`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
const result = await handleResponse<{ success: boolean; settings: Settings }>(response);
|
||||
return result.settings;
|
||||
},
|
||||
|
||||
async updateSettings(settings: Partial<Settings>) {
|
||||
const response = await fetch(`${API_BASE_URL}/settings`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(settings),
|
||||
});
|
||||
return handleResponse<{ success: boolean; message: string }>(response);
|
||||
},
|
||||
};
|
||||
83
lib/auth-context.tsx
Normal file
83
lib/auth-context.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||
import { authApi } from './api';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
interface AuthContextType {
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
username: string | null;
|
||||
login: (username: string, password: string) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const [username, setUsername] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const router = useRouter();
|
||||
|
||||
// Check authentication on mount using httpOnly cookies
|
||||
useEffect(() => {
|
||||
const initAuth = async () => {
|
||||
try {
|
||||
// Call /api/auth/me - cookies are sent automatically
|
||||
const user = await authApi.me();
|
||||
setIsAuthenticated(true);
|
||||
setUsername(user.username);
|
||||
} catch (error) {
|
||||
// Not authenticated or session expired
|
||||
setIsAuthenticated(false);
|
||||
setUsername(null);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
initAuth();
|
||||
}, []);
|
||||
|
||||
const login = async (username: string, password: string) => {
|
||||
// Server sets httpOnly cookies automatically
|
||||
await authApi.login(username, password);
|
||||
setIsAuthenticated(true);
|
||||
setUsername(username);
|
||||
};
|
||||
|
||||
const logout = async () => {
|
||||
try {
|
||||
// Server clears httpOnly cookies
|
||||
await authApi.logout();
|
||||
} catch (error) {
|
||||
// Continue with logout even if API call fails
|
||||
}
|
||||
setIsAuthenticated(false);
|
||||
setUsername(null);
|
||||
router.push('/');
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
value={{
|
||||
isAuthenticated,
|
||||
isLoading,
|
||||
username,
|
||||
login,
|
||||
logout,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
130
lib/auth/utils.ts
Normal file
130
lib/auth/utils.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import jwt from 'jsonwebtoken';
|
||||
import crypto from 'crypto';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
// Initialize secrets (auto-generate if not provided)
|
||||
const secrets = initializeSecrets();
|
||||
|
||||
// Token expiry times
|
||||
const TOKEN_EXPIRY = '72h';
|
||||
const REFRESH_TOKEN_EXPIRY = '30d';
|
||||
|
||||
/**
|
||||
* Initialize JWT secrets - auto-generate and persist if not provided in environment
|
||||
*/
|
||||
function initializeSecrets(): { secret: string; refreshSecret: string } {
|
||||
const dataDir = path.join(process.cwd(), 'data');
|
||||
const secretsFile = path.join(dataDir, 'secrets.json');
|
||||
|
||||
// If provided in environment, use those
|
||||
if (process.env.SECRET) {
|
||||
return {
|
||||
secret: process.env.SECRET,
|
||||
refreshSecret: process.env.REFRESH_SECRET || process.env.SECRET, // Use same secret if refresh not provided
|
||||
};
|
||||
}
|
||||
|
||||
// Ensure data directory exists
|
||||
if (!fs.existsSync(dataDir)) {
|
||||
fs.mkdirSync(dataDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Try to load existing secrets
|
||||
if (fs.existsSync(secretsFile)) {
|
||||
try {
|
||||
const data = JSON.parse(fs.readFileSync(secretsFile, 'utf-8'));
|
||||
return data;
|
||||
} catch {
|
||||
// Failed to load, will generate new ones
|
||||
}
|
||||
}
|
||||
|
||||
// Generate new cryptographically secure secrets (512 bits each)
|
||||
const newSecrets = {
|
||||
secret: crypto.randomBytes(64).toString('hex'),
|
||||
refreshSecret: crypto.randomBytes(64).toString('hex'),
|
||||
};
|
||||
|
||||
// Save to file with restricted permissions
|
||||
try {
|
||||
fs.writeFileSync(secretsFile, JSON.stringify(newSecrets, null, 2), { mode: 0o600 });
|
||||
} catch (error) {
|
||||
console.error('⚠️ Failed to save secrets file:', error);
|
||||
console.error('⚠️ WARNING: Using in-memory secrets - tokens will be invalid after restart!');
|
||||
}
|
||||
|
||||
return newSecrets;
|
||||
}
|
||||
|
||||
export interface TokenPayload {
|
||||
username: string;
|
||||
type: 'access' | 'refresh';
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate an access token
|
||||
*/
|
||||
export function generateAccessToken(username: string): string {
|
||||
return jwt.sign(
|
||||
{ username, type: 'access' } as TokenPayload,
|
||||
secrets.secret,
|
||||
{ expiresIn: TOKEN_EXPIRY } as jwt.SignOptions
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a refresh token
|
||||
*/
|
||||
export function generateRefreshToken(username: string): string {
|
||||
return jwt.sign(
|
||||
{ username, type: 'refresh' } as TokenPayload,
|
||||
secrets.refreshSecret,
|
||||
{ expiresIn: REFRESH_TOKEN_EXPIRY } as jwt.SignOptions
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify an access token
|
||||
*/
|
||||
export function verifyAccessToken(token: string): TokenPayload | null {
|
||||
try {
|
||||
const payload = jwt.verify(token, secrets.secret) as TokenPayload;
|
||||
if (payload.type !== 'access') {
|
||||
return null;
|
||||
}
|
||||
return payload;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a refresh token
|
||||
*/
|
||||
export function verifyRefreshToken(token: string): TokenPayload | null {
|
||||
try {
|
||||
const payload = jwt.verify(token, secrets.refreshSecret) as TokenPayload;
|
||||
if (payload.type !== 'refresh') {
|
||||
return null;
|
||||
}
|
||||
return payload;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate admin credentials against environment variables
|
||||
*/
|
||||
export function validateAdminCredentials(username: string, password: string): boolean {
|
||||
const adminUsername = process.env.ADMIN_USERNAME || 'admin';
|
||||
const adminPassword = process.env.ADMIN_PASSWORD;
|
||||
|
||||
if (!adminPassword) {
|
||||
console.error('❌ ADMIN_PASSWORD not set in environment variables');
|
||||
return false;
|
||||
}
|
||||
|
||||
return username === adminUsername && password === adminPassword;
|
||||
}
|
||||
146
lib/db/index.ts
Normal file
146
lib/db/index.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { drizzle } from 'drizzle-orm/better-sqlite3';
|
||||
import * as schema from './schema';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
|
||||
// Lazy initialization to avoid database access during build
|
||||
let _db: ReturnType<typeof drizzle> | null = null;
|
||||
let _sqlite: Database.Database | null = null;
|
||||
|
||||
function getDb() {
|
||||
if (!_db) {
|
||||
// Ensure data directories exist
|
||||
const dataDir = path.join(process.cwd(), 'data');
|
||||
const dbDir = path.join(dataDir, 'db');
|
||||
const uploadsDir = path.join(dataDir, 'uploads');
|
||||
|
||||
if (!fs.existsSync(dbDir)) {
|
||||
fs.mkdirSync(dbDir, { recursive: true });
|
||||
}
|
||||
|
||||
if (!fs.existsSync(uploadsDir)) {
|
||||
fs.mkdirSync(uploadsDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Database file path
|
||||
const dbPath = path.join(dbDir, 'wishlist.db');
|
||||
|
||||
// Create SQLite database connection
|
||||
_sqlite = new Database(dbPath);
|
||||
_sqlite.pragma('journal_mode = WAL'); // Better concurrency
|
||||
|
||||
// Create Drizzle instance
|
||||
_db = drizzle(_sqlite, { schema });
|
||||
}
|
||||
return _db;
|
||||
}
|
||||
|
||||
// Export db as a getter
|
||||
export const db = new Proxy({} as ReturnType<typeof drizzle>, {
|
||||
get(target, prop) {
|
||||
return getDb()[prop as keyof ReturnType<typeof drizzle>];
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize database (create tables and seed if needed)
|
||||
export async function initializeDatabase() {
|
||||
try {
|
||||
// Ensure database is initialized
|
||||
const sqlite = _sqlite || getDb() && _sqlite;
|
||||
if (!sqlite) throw new Error('Failed to initialize database');
|
||||
|
||||
// Create wishlists table
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS wishlists (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
description TEXT,
|
||||
preferences TEXT,
|
||||
image_url TEXT,
|
||||
notes TEXT,
|
||||
is_public INTEGER DEFAULT 0 NOT NULL,
|
||||
sort_order INTEGER DEFAULT 0 NOT NULL,
|
||||
created_at INTEGER DEFAULT (unixepoch()) NOT NULL,
|
||||
updated_at INTEGER DEFAULT (unixepoch()) NOT NULL
|
||||
)
|
||||
`);
|
||||
|
||||
// Create wishlist_items table
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS wishlist_items (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
wishlist_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
price REAL,
|
||||
currency TEXT DEFAULT 'USD' NOT NULL,
|
||||
quantity INTEGER DEFAULT 1 NOT NULL,
|
||||
images TEXT,
|
||||
purchase_urls TEXT,
|
||||
notes TEXT,
|
||||
is_archived INTEGER DEFAULT 0 NOT NULL,
|
||||
claimed_by_name TEXT,
|
||||
claimed_by_note TEXT,
|
||||
claimed_by_token TEXT UNIQUE,
|
||||
claimed_at INTEGER,
|
||||
is_purchased INTEGER DEFAULT 0 NOT NULL,
|
||||
sort_order INTEGER DEFAULT 0 NOT NULL,
|
||||
created_at INTEGER DEFAULT (unixepoch()) NOT NULL,
|
||||
updated_at INTEGER DEFAULT (unixepoch()) NOT NULL,
|
||||
FOREIGN KEY (wishlist_id) REFERENCES wishlists(id) ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
|
||||
// Create settings table
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
key TEXT NOT NULL UNIQUE,
|
||||
value TEXT NOT NULL,
|
||||
updated_at INTEGER DEFAULT (unixepoch()) NOT NULL
|
||||
)
|
||||
`);
|
||||
|
||||
// Run migrations for existing databases
|
||||
try {
|
||||
const columns = sqlite.pragma('table_info(wishlists)') as Array<{ name: string }>;
|
||||
|
||||
// Add image_url column if it doesn't exist
|
||||
const hasImageUrl = columns.some((col) => col.name === 'image_url');
|
||||
if (!hasImageUrl) {
|
||||
sqlite.exec('ALTER TABLE wishlists ADD COLUMN image_url TEXT');
|
||||
console.log('✅ Added image_url column to wishlists table');
|
||||
}
|
||||
|
||||
// Add sort_order column if it doesn't exist
|
||||
const hasSortOrder = columns.some((col) => col.name === 'sort_order');
|
||||
if (!hasSortOrder) {
|
||||
sqlite.exec('ALTER TABLE wishlists ADD COLUMN sort_order INTEGER DEFAULT 0 NOT NULL');
|
||||
console.log('✅ Added sort_order column to wishlists table');
|
||||
}
|
||||
|
||||
// Add preferences column if it doesn't exist
|
||||
const hasPreferences = columns.some((col) => col.name === 'preferences');
|
||||
if (!hasPreferences) {
|
||||
sqlite.exec('ALTER TABLE wishlists ADD COLUMN preferences TEXT');
|
||||
console.log('✅ Added preferences column to wishlists table');
|
||||
}
|
||||
} catch (migrationError) {
|
||||
console.log('Migration already applied or not needed');
|
||||
}
|
||||
|
||||
// Auto-seed database if empty
|
||||
const { seedDatabase } = await import('./seed');
|
||||
await seedDatabase();
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('❌ Database initialization failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Export schema for use in other files
|
||||
export * from './schema';
|
||||
68
lib/db/schema.ts
Normal file
68
lib/db/schema.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { sqliteTable, text, integer, real } from 'drizzle-orm/sqlite-core';
|
||||
import { createId } from '@paralleldrive/cuid2';
|
||||
import { sql } from 'drizzle-orm';
|
||||
|
||||
/**
|
||||
* Database schema for family wishlist app
|
||||
*
|
||||
* Simplified for self-hosted family use:
|
||||
* - No Settings table (use env vars instead)
|
||||
* - Image URLs only (no upload handling in MVP)
|
||||
* - Simplified purchase URLs (no usage tracking)
|
||||
*/
|
||||
|
||||
// Wishlists table
|
||||
export const wishlists = sqliteTable('wishlists', {
|
||||
id: text('id').primaryKey().$defaultFn(() => createId()),
|
||||
name: text('name').notNull(),
|
||||
slug: text('slug').notNull().unique(),
|
||||
description: text('description'),
|
||||
preferences: text('preferences'), // General interests/likes section
|
||||
imageUrl: text('image_url'),
|
||||
isPublic: integer('is_public', { mode: 'boolean' }).notNull().default(false),
|
||||
sortOrder: integer('sort_order').notNull().default(0),
|
||||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
|
||||
});
|
||||
|
||||
// Wishlist items table
|
||||
export const wishlistItems = sqliteTable('wishlist_items', {
|
||||
id: text('id').primaryKey().$defaultFn(() => createId()),
|
||||
wishlistId: text('wishlist_id').notNull().references(() => wishlists.id, { onDelete: 'cascade' }),
|
||||
name: text('name').notNull(),
|
||||
description: text('description'),
|
||||
price: real('price'),
|
||||
currency: text('currency').notNull().default('USD'),
|
||||
quantity: integer('quantity').notNull().default(1),
|
||||
imageUrl: text('images'), // Stored as 'images' in DB but exposed as imageUrl
|
||||
purchaseUrls: text('purchase_urls', { mode: 'json' }).$type<Array<{
|
||||
label: string;
|
||||
url: string;
|
||||
}>>(),
|
||||
|
||||
isArchived: integer('is_archived', { mode: 'boolean' }).notNull().default(false),
|
||||
|
||||
// Claim information
|
||||
claimedByName: text('claimed_by_name'),
|
||||
claimedByNote: text('claimed_by_note'),
|
||||
claimedByToken: text('claimed_by_token').unique(),
|
||||
claimedAt: integer('claimed_at', { mode: 'timestamp' }),
|
||||
isPurchased: integer('is_purchased', { mode: 'boolean' }).notNull().default(false),
|
||||
|
||||
sortOrder: integer('sort_order').notNull().default(0),
|
||||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
|
||||
});
|
||||
|
||||
// Settings table
|
||||
export const settings = sqliteTable('settings', {
|
||||
id: text('id').primaryKey().$defaultFn(() => createId()),
|
||||
key: text('key').notNull().unique(),
|
||||
value: text('value').notNull(),
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
|
||||
});
|
||||
|
||||
// Type exports
|
||||
export type Wishlist = typeof wishlists.$inferSelect;
|
||||
export type WishlistItem = typeof wishlistItems.$inferSelect;
|
||||
export type Setting = typeof settings.$inferSelect;
|
||||
303
lib/db/seed.ts
Normal file
303
lib/db/seed.ts
Normal file
@@ -0,0 +1,303 @@
|
||||
import { db } from './index';
|
||||
import { wishlists, wishlistItems, settings } from './schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
/**
|
||||
* Seed the database with sample data
|
||||
* This creates example wishlists and items to help users get started
|
||||
*/
|
||||
export async function seedDatabase() {
|
||||
try {
|
||||
// Check if data already exists
|
||||
const existingWishlists = await db.select().from(wishlists).limit(1);
|
||||
if (existingWishlists.length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Seed settings
|
||||
await db.insert(settings).values([
|
||||
{
|
||||
key: 'siteTitle',
|
||||
value: 'Wishlist',
|
||||
},
|
||||
{
|
||||
key: 'homepageSubtext',
|
||||
value: 'Hello! Thank you so much for thinking of us! When you purchase something from our list, just click "Claim" to mark it. We promise not to peek at what\'s been claimed. This works on the honor system, so please only claim items you\'ve actually bought. We appreciate you!',
|
||||
},
|
||||
]);
|
||||
|
||||
// Create sample wishlists
|
||||
const [dadWishlist] = await db.insert(wishlists).values({
|
||||
name: "Dad's Wishlist",
|
||||
slug: 'dads-wishlist',
|
||||
description: 'Birthday and holiday gift ideas',
|
||||
imageUrl: '/images/wishlists/dad.png',
|
||||
isPublic: true,
|
||||
}).returning();
|
||||
|
||||
const [momWishlist] = await db.insert(wishlists).values({
|
||||
name: "Mom's Wishlist",
|
||||
slug: 'moms-wishlist',
|
||||
description: 'Things I would love!',
|
||||
imageUrl: '/images/wishlists/mom.png',
|
||||
isPublic: true,
|
||||
}).returning();
|
||||
|
||||
const [childWishlist] = await db.insert(wishlists).values({
|
||||
name: "Child's Wishlist",
|
||||
slug: 'childs-wishlist',
|
||||
description: 'Toys, books, and fun things!',
|
||||
imageUrl: '/images/wishlists/child.png',
|
||||
isPublic: true,
|
||||
}).returning();
|
||||
|
||||
// Dad's wishlist items
|
||||
await db.insert(wishlistItems).values([
|
||||
{
|
||||
wishlistId: dadWishlist.id,
|
||||
name: 'The Book of Unusual Knowledge: Big Book of Fascinating Facts & Information',
|
||||
description: 'I love learning random trivia and this looks like the perfect coffee table book to flip through when I have a few minutes.',
|
||||
price: 10.00,
|
||||
currency: 'USD',
|
||||
quantity: 1,
|
||||
sortOrder: 0,
|
||||
imageUrl: '/images/items/dad1.jpg',
|
||||
purchaseUrls: [
|
||||
{ label: 'Amazon - $10.00', url: 'https://www.amazon.com/Unusual-Knowledge-Editors-Publications-International/dp/1450845800/ref=sr_1_6' },
|
||||
],
|
||||
},
|
||||
{
|
||||
wishlistId: dadWishlist.id,
|
||||
name: 'Anker PowerCore 10000 Portable Charger',
|
||||
description: 'My phone always dies when I need it most. This would be perfect for keeping in my bag for emergencies.',
|
||||
price: 26.00,
|
||||
currency: 'USD',
|
||||
quantity: 1,
|
||||
sortOrder: 1,
|
||||
imageUrl: '/images/items/dad2.jpg',
|
||||
purchaseUrls: [
|
||||
{ label: 'Amazon - $26.00', url: 'https://www.amazon.com/Anker-PowerCore-Ultra-Compact-High-Speed-Technology/dp/B0194WDVHI' },
|
||||
{ label: 'Micro Center - $29.99', url: 'https://www.microcenter.com/product/686695/anker-10k-225w-power-bank' },
|
||||
{ label: 'Staples - $34.99', url: 'https://www.staples.com/anker-powercore-power-bank-10000mah-22-5w-portable-charger-with-usb-c-lanyard-cable-black-a1388h11-1/product_24617552' },
|
||||
],
|
||||
},
|
||||
{
|
||||
wishlistId: dadWishlist.id,
|
||||
name: 'Yeti Rambler 20 oz Tumbler',
|
||||
description: 'I want something that keeps my coffee hot during my entire commute. This looks like it would be perfect.',
|
||||
price: 30.00,
|
||||
currency: 'USD',
|
||||
quantity: 1,
|
||||
sortOrder: 2,
|
||||
imageUrl: '/images/items/dad3.webp',
|
||||
purchaseUrls: [
|
||||
{ label: 'Amazon - $30.00', url: 'https://www.amazon.com/YETI-Rambler-Tumbler-Vacuum-Insulated/dp/B073WKWYJJ' },
|
||||
{ label: 'YETI - $30.00', url: 'https://www.yeti.com/drinkware/tumblers/rambler-20-oz-tumbler.html' },
|
||||
{ label: 'REI - $30.00', url: 'https://www.rei.com/product/113804/yeti-rambler-20-fl-oz-tumbler' },
|
||||
],
|
||||
},
|
||||
{
|
||||
wishlistId: dadWishlist.id,
|
||||
name: 'Tile Mate Bluetooth Tracker 4-Pack',
|
||||
description: 'I lose my keys at least twice a week. These would save me so much time and frustration.',
|
||||
price: 70.00,
|
||||
currency: 'USD',
|
||||
quantity: 1,
|
||||
sortOrder: 3,
|
||||
imageUrl: '/images/items/dad4.webp',
|
||||
purchaseUrls: [
|
||||
{ label: 'Amazon - $70.00', url: 'https://www.amazon.com/Tile-Mate-4-Pack-Bluetooth-Finder/dp/B09B2WLRWH' },
|
||||
{ label: 'Best Buy - $74.99', url: 'https://www.bestbuy.com/site/tile-mate-bluetooth-tracker-4-pack/6451674.p' },
|
||||
{ label: 'Target - $69.99', url: 'https://www.target.com/p/tile-mate-bluetooth-tracker-4pk/-/A-82215989' },
|
||||
],
|
||||
},
|
||||
{
|
||||
wishlistId: dadWishlist.id,
|
||||
name: 'Leatherman Wave Plus Multi-Tool',
|
||||
description: 'I always need a tool when I don\'t have one. This has everything in one compact package.',
|
||||
price: 120.00,
|
||||
currency: 'USD',
|
||||
quantity: 1,
|
||||
sortOrder: 4,
|
||||
imageUrl: '/images/items/dad5.jpg',
|
||||
purchaseUrls: [
|
||||
{ label: 'Amazon - $120.00', url: 'https://www.amazon.com/LEATHERMAN-Wave-Multitool-Black-Molle/dp/B07DD69QN3' },
|
||||
{ label: 'REI - $124.95', url: 'https://www.rei.com/product/766953/leatherman-wave-plus-multitool' },
|
||||
{ label: 'Home Depot - $119.99', url: 'https://www.homedepot.com/p/LEATHERMAN-Wave-Plus-Multi-Tool-832524/305408085' },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
// Mom's wishlist items
|
||||
await db.insert(wishlistItems).values([
|
||||
{
|
||||
wishlistId: momWishlist.id,
|
||||
name: 'Kindle Paperwhite (16 GB)',
|
||||
description: 'I love reading before bed and this would be perfect for not disturbing anyone with a lamp.',
|
||||
price: 140.00,
|
||||
currency: 'USD',
|
||||
quantity: 1,
|
||||
sortOrder: 0,
|
||||
imageUrl: '/images/items/mom1.jpg',
|
||||
purchaseUrls: [
|
||||
{ label: 'Amazon - $139.99', url: 'https://www.amazon.com/Kindle-Paperwhite-16-GB/dp/B08N3J8GTX' },
|
||||
{ label: 'Best Buy - $139.99', url: 'https://www.bestbuy.com/site/amazon-kindle-paperwhite-16gb/6522383.p' },
|
||||
{ label: 'Target - $139.99', url: 'https://www.target.com/p/kindle-paperwhite/-/A-84491392' },
|
||||
],
|
||||
},
|
||||
{
|
||||
wishlistId: momWishlist.id,
|
||||
name: 'Lululemon Align High-Rise Pant 25"',
|
||||
description: 'I need comfortable leggings for yoga class and these are supposed to be amazing.',
|
||||
price: 98.00,
|
||||
currency: 'USD',
|
||||
quantity: 1,
|
||||
sortOrder: 1,
|
||||
imageUrl: '/images/items/mom2.webp',
|
||||
purchaseUrls: [
|
||||
{ label: 'Lululemon - $98.00', url: 'https://shop.lululemon.com/p/women-pants/Align-Pant-2' },
|
||||
{ label: 'Amazon - $98.00', url: 'https://www.amazon.com/stores/page/lululemon' },
|
||||
],
|
||||
},
|
||||
{
|
||||
wishlistId: momWishlist.id,
|
||||
name: 'Hydro Flask 32 oz Wide Mouth Water Bottle',
|
||||
description: 'I want to drink more water throughout the day and this keeps drinks cold for 24 hours.',
|
||||
price: 45.00,
|
||||
currency: 'USD',
|
||||
quantity: 1,
|
||||
sortOrder: 2,
|
||||
imageUrl: '/images/items/mom3.jpg',
|
||||
purchaseUrls: [
|
||||
{ label: 'Amazon - $44.95', url: 'https://www.amazon.com/Hydro-Flask-Water-Bottle-Stainless/dp/B01ACVS6RE' },
|
||||
{ label: 'REI - $44.95', url: 'https://www.rei.com/product/889468/hydro-flask-wide-mouth-water-bottle-32-fl-oz' },
|
||||
{ label: 'Dick\'s Sporting Goods - $44.99', url: 'https://www.dickssportinggoods.com/p/hydro-flask-32-oz-wide-mouth-bottle/16hflu32zwd' },
|
||||
],
|
||||
},
|
||||
{
|
||||
wishlistId: momWishlist.id,
|
||||
name: 'Revlon One-Step Volumizer Hair Dryer',
|
||||
description: 'My hair takes forever to style and this looks like it would save me so much time in the morning.',
|
||||
price: 40.00,
|
||||
currency: 'USD',
|
||||
quantity: 1,
|
||||
sortOrder: 3,
|
||||
imageUrl: '/images/items/mom4.jpg',
|
||||
purchaseUrls: [
|
||||
{ label: 'Amazon - $39.99', url: 'https://www.amazon.com/Revlon-One-Step-Dryer-Volumizer/dp/B01LSUQSB0' },
|
||||
{ label: 'Target - $39.99', url: 'https://www.target.com/p/revlon-one-step-volumizer/-/A-53003976' },
|
||||
{ label: 'Walmart - $39.96', url: 'https://www.walmart.com/ip/Revlon-One-Step-Hair-Dryer-Volumizer/55689116' },
|
||||
],
|
||||
},
|
||||
{
|
||||
wishlistId: momWishlist.id,
|
||||
name: 'Apple AirPods Pro (2nd Generation)',
|
||||
description: 'I need better earbuds for my workouts and calls. These have noise cancellation which would be perfect for the gym.',
|
||||
price: 249.00,
|
||||
currency: 'USD',
|
||||
quantity: 1,
|
||||
sortOrder: 4,
|
||||
imageUrl: '/images/items/mom5.jpg',
|
||||
purchaseUrls: [
|
||||
{ label: 'Amazon - $249.00', url: 'https://www.amazon.com/Apple-Generation-Cancelling-Transparency-Personalized/dp/B0CHWRXH8B' },
|
||||
{ label: 'Best Buy - $249.99', url: 'https://www.bestbuy.com/site/apple-airpods-pro-2nd-generation/6447382.p' },
|
||||
{ label: 'Target - $249.99', url: 'https://www.target.com/p/apple-airpods-pro-2nd-generation/-/A-85978622' },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
// Child's wishlist items
|
||||
await db.insert(wishlistItems).values([
|
||||
{
|
||||
wishlistId: childWishlist.id,
|
||||
name: 'LEGO Classic Medium Creative Brick Box',
|
||||
description: 'I love building things and making my own creations! This has so many pieces to build anything I can imagine.',
|
||||
price: 30.00,
|
||||
currency: 'USD',
|
||||
quantity: 1,
|
||||
sortOrder: 0,
|
||||
imageUrl: '/images/items/child1.jpg',
|
||||
purchaseUrls: [
|
||||
{ label: 'Amazon - $29.99', url: 'https://www.amazon.com/LEGO-Classic-Medium-Creative-Construction/dp/B00NHQFA3Y' },
|
||||
{ label: 'Target - $29.99', url: 'https://www.target.com/p/lego-classic-medium-creative-brick-box/-/A-14182781' },
|
||||
{ label: 'Walmart - $29.97', url: 'https://www.walmart.com/ip/LEGO-Classic-Medium-Creative-Brick-Box-10696/34611691' },
|
||||
],
|
||||
},
|
||||
{
|
||||
wishlistId: childWishlist.id,
|
||||
name: 'Crayola Ultimate Crayon Collection',
|
||||
description: 'I need more colors for my drawings! This has 152 crayons with all the colors I could ever want.',
|
||||
price: 20.00,
|
||||
currency: 'USD',
|
||||
quantity: 1,
|
||||
sortOrder: 1,
|
||||
imageUrl: '/images/items/child2.jpg',
|
||||
purchaseUrls: [
|
||||
{ label: 'Amazon - $19.99', url: 'https://www.amazon.com/Crayola-Ultimate-Crayon-Collection-Colors/dp/B00LH32JDE' },
|
||||
{ label: 'Target - $19.99', url: 'https://www.target.com/p/crayola-ultimate-crayon-collection/-/A-14676404' },
|
||||
{ label: 'Walmart - $19.94', url: 'https://www.walmart.com/ip/Crayola-Ultimate-Crayon-Collection-152-Colors/26228172' },
|
||||
],
|
||||
},
|
||||
{
|
||||
wishlistId: childWishlist.id,
|
||||
name: 'National Geographic Kids World Atlas',
|
||||
description: 'I want to learn about all the different countries and places in the world. This book has cool maps and pictures!',
|
||||
price: 25.00,
|
||||
currency: 'USD',
|
||||
quantity: 1,
|
||||
sortOrder: 2,
|
||||
imageUrl: '/images/items/child3.jpg',
|
||||
purchaseUrls: [
|
||||
{ label: 'Amazon - $24.99', url: 'https://www.amazon.com/National-Geographic-Kids-World-Atlas/dp/1426375905' },
|
||||
{ label: 'Barnes & Noble - $24.99', url: 'https://www.barnesandnoble.com/w/national-geographic-kids-world-atlas/1141356542' },
|
||||
{ label: 'Target - $24.99', url: 'https://www.target.com/p/national-geographic-kids-world-atlas/-/A-87621944' },
|
||||
],
|
||||
},
|
||||
{
|
||||
wishlistId: childWishlist.id,
|
||||
name: 'Razor A Kick Scooter',
|
||||
description: 'All my friends have scooters and I really want one too so I can ride with them to the park!',
|
||||
price: 40.00,
|
||||
currency: 'USD',
|
||||
quantity: 1,
|
||||
sortOrder: 3,
|
||||
imageUrl: '/images/items/child4.jpg',
|
||||
purchaseUrls: [
|
||||
{ label: 'Amazon - $39.99', url: 'https://www.amazon.com/Razor-Kick-Scooter-FFP/dp/B00005NBON' },
|
||||
{ label: 'Target - $39.99', url: 'https://www.target.com/p/razor-a-kick-scooter/-/A-10853734' },
|
||||
{ label: 'Walmart - $39.88', url: 'https://www.walmart.com/ip/Razor-A-Kick-Scooter/10314287' },
|
||||
],
|
||||
},
|
||||
{
|
||||
wishlistId: childWishlist.id,
|
||||
name: 'Melissa & Doug Wooden Building Blocks Set',
|
||||
description: 'I like making towers and houses with blocks. These wooden ones look really cool and sturdy!',
|
||||
price: 35.00,
|
||||
currency: 'USD',
|
||||
quantity: 1,
|
||||
sortOrder: 4,
|
||||
imageUrl: '/images/items/child5.jpg',
|
||||
purchaseUrls: [
|
||||
{ label: 'Amazon - $34.99', url: 'https://www.amazon.com/Melissa-Doug-Standard-Wooden-Building/dp/B00005RF5G' },
|
||||
{ label: 'Target - $34.99', url: 'https://www.target.com/p/melissa-doug-wooden-building-blocks-set/-/A-10917067' },
|
||||
{ label: 'Walmart - $34.97', url: 'https://www.walmart.com/ip/Melissa-Doug-Wooden-Building-Blocks-Set/5024533' },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('❌ Database seeding failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Run seed if called directly
|
||||
if (require.main === module) {
|
||||
seedDatabase()
|
||||
.then(() => process.exit(0))
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
348
lib/scraping/service.ts
Normal file
348
lib/scraping/service.ts
Normal file
@@ -0,0 +1,348 @@
|
||||
import axios from 'axios';
|
||||
import * as cheerio from 'cheerio';
|
||||
|
||||
export interface ScrapedData {
|
||||
title: string | null;
|
||||
description: string | null;
|
||||
price: number | null;
|
||||
currency: string | null;
|
||||
imageUrl: string | null;
|
||||
url: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch and parse HTML from a URL
|
||||
*/
|
||||
async function fetchHtml(url: string): Promise<string> {
|
||||
try {
|
||||
const response = await axios.get(url, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
|
||||
},
|
||||
timeout: 10000, // 10 second timeout
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to fetch URL: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract price from text (e.g., "$99.99", "99,99 €", "£50")
|
||||
*/
|
||||
function extractPrice(text: string): { price: number | null; currency: string | null } {
|
||||
// Remove whitespace and normalize
|
||||
const normalized = text.trim().replace(/\s+/g, '');
|
||||
|
||||
// Match currency symbols and numbers
|
||||
// Patterns: $99.99, 99.99 USD, €99,99, £50.00, etc.
|
||||
const patterns = [
|
||||
/\$(\d+(?:,\d{3})*(?:\.\d{2})?)/, // $99.99, $1,299.99
|
||||
/(\d+(?:,\d{3})*(?:\.\d{2})?)\s*USD/i, // 99.99 USD
|
||||
/€(\d+(?:\.\d{3})*(?:,\d{2})?)/, // €99,99
|
||||
/£(\d+(?:,\d{3})*(?:\.\d{2})?)/, // £99.99
|
||||
/(\d+(?:,\d{3})*(?:\.\d{2})?)/, // Fallback: just numbers
|
||||
];
|
||||
|
||||
for (const pattern of patterns) {
|
||||
const match = normalized.match(pattern);
|
||||
if (match) {
|
||||
// Extract number and remove commas, convert to float
|
||||
const priceStr = match[1].replace(/,/g, '');
|
||||
const price = parseFloat(priceStr);
|
||||
|
||||
if (!isNaN(price)) {
|
||||
// Determine currency
|
||||
let currency = 'USD';
|
||||
if (normalized.includes('$')) currency = 'USD';
|
||||
else if (normalized.includes('€')) currency = 'EUR';
|
||||
else if (normalized.includes('£')) currency = 'GBP';
|
||||
else if (normalized.match(/USD/i)) currency = 'USD';
|
||||
else if (normalized.match(/EUR/i)) currency = 'EUR';
|
||||
else if (normalized.match(/GBP/i)) currency = 'GBP';
|
||||
|
||||
return { price, currency };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { price: null, currency: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic scraper using Open Graph and meta tags
|
||||
*/
|
||||
function scrapeGeneric(html: string, url: string): ScrapedData {
|
||||
const $ = cheerio.load(html);
|
||||
|
||||
// Try Open Graph tags first
|
||||
const ogTitle = $('meta[property="og:title"]').attr('content');
|
||||
const ogDescription = $('meta[property="og:description"]').attr('content');
|
||||
const ogImage = $('meta[property="og:image"]').attr('content');
|
||||
const ogPriceAmount = $('meta[property="og:price:amount"]').attr('content');
|
||||
const ogPriceCurrency = $('meta[property="og:price:currency"]').attr('content');
|
||||
|
||||
// Fallback to other meta tags
|
||||
const metaDescription = $('meta[name="description"]').attr('content');
|
||||
const title = ogTitle || $('title').text().trim() || null;
|
||||
const description = ogDescription || metaDescription || null;
|
||||
const imageUrl = ogImage || $('link[rel="image_src"]').attr('href') || null;
|
||||
|
||||
// Try to extract price from OG tags or page content
|
||||
let price: number | null = null;
|
||||
let currency: string | null = null;
|
||||
|
||||
if (ogPriceAmount && ogPriceCurrency) {
|
||||
price = parseFloat(ogPriceAmount);
|
||||
currency = ogPriceCurrency;
|
||||
} else {
|
||||
// Try to find price in common selectors
|
||||
const priceSelectors = [
|
||||
'.price',
|
||||
'[data-price]',
|
||||
'.product-price',
|
||||
'[itemprop="price"]',
|
||||
'.a-price .a-offscreen', // Amazon
|
||||
];
|
||||
|
||||
for (const selector of priceSelectors) {
|
||||
const priceText = $(selector).first().text();
|
||||
if (priceText) {
|
||||
const extracted = extractPrice(priceText);
|
||||
if (extracted.price !== null) {
|
||||
price = extracted.price;
|
||||
currency = extracted.currency;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
price,
|
||||
currency,
|
||||
imageUrl,
|
||||
url,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Amazon-specific scraper
|
||||
*/
|
||||
function scrapeAmazon(html: string, url: string): ScrapedData {
|
||||
const $ = cheerio.load(html);
|
||||
|
||||
// Amazon-specific selectors
|
||||
const title = $('#productTitle').text().trim() ||
|
||||
$('span[id="productTitle"]').text().trim() ||
|
||||
null;
|
||||
|
||||
const description = $('#feature-bullets ul li').first().text().trim() ||
|
||||
$('meta[name="description"]').attr('content') ||
|
||||
null;
|
||||
|
||||
// Amazon price selectors (they change frequently)
|
||||
let price: number | null = null;
|
||||
let currency: string | null = null;
|
||||
|
||||
const priceWhole = $('.a-price-whole').first().text().trim();
|
||||
const priceFraction = $('.a-price-fraction').first().text().trim();
|
||||
|
||||
if (priceWhole) {
|
||||
const priceStr = priceWhole.replace(',', '') + (priceFraction || '00');
|
||||
price = parseFloat(priceStr);
|
||||
currency = 'USD'; // Default, could be enhanced to detect from page
|
||||
}
|
||||
|
||||
// Fallback to other price selectors
|
||||
if (price === null) {
|
||||
const priceSelectors = [
|
||||
'.a-price .a-offscreen',
|
||||
'#priceblock_ourprice',
|
||||
'#priceblock_dealprice',
|
||||
'.a-price-whole',
|
||||
];
|
||||
|
||||
for (const selector of priceSelectors) {
|
||||
const priceText = $(selector).first().text();
|
||||
if (priceText) {
|
||||
const extracted = extractPrice(priceText);
|
||||
if (extracted.price !== null) {
|
||||
price = extracted.price;
|
||||
currency = extracted.currency;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Amazon images
|
||||
const imageUrl = $('#landingImage').attr('src') ||
|
||||
$('#imgBlkFront').attr('src') ||
|
||||
$('img[data-old-hires]').attr('data-old-hires') ||
|
||||
$('meta[property="og:image"]').attr('content') ||
|
||||
null;
|
||||
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
price,
|
||||
currency,
|
||||
imageUrl,
|
||||
url,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Target-specific scraper
|
||||
*/
|
||||
function scrapeTarget(html: string, url: string): ScrapedData {
|
||||
const $ = cheerio.load(html);
|
||||
|
||||
const title = $('h1[data-test="product-title"]').text().trim() ||
|
||||
$('meta[property="og:title"]').attr('content') ||
|
||||
null;
|
||||
|
||||
const description = $('div[data-test="item-details-description"]').text().trim() ||
|
||||
$('meta[property="og:description"]').attr('content') ||
|
||||
null;
|
||||
|
||||
let price: number | null = null;
|
||||
let currency = 'USD';
|
||||
|
||||
const priceText = $('span[data-test="product-price"]').first().text() ||
|
||||
$('div[data-test="product-price"]').first().text();
|
||||
|
||||
if (priceText) {
|
||||
const extracted = extractPrice(priceText);
|
||||
price = extracted.price;
|
||||
currency = extracted.currency || 'USD';
|
||||
}
|
||||
|
||||
const imageUrl = $('img[data-test="product-image"]').attr('src') ||
|
||||
$('meta[property="og:image"]').attr('content') ||
|
||||
null;
|
||||
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
price,
|
||||
currency,
|
||||
imageUrl,
|
||||
url,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Walmart-specific scraper
|
||||
*/
|
||||
function scrapeWalmart(html: string, url: string): ScrapedData {
|
||||
const $ = cheerio.load(html);
|
||||
|
||||
const title = $('h1[itemprop="name"]').text().trim() ||
|
||||
$('meta[property="og:title"]').attr('content') ||
|
||||
null;
|
||||
|
||||
const description = $('div[itemprop="description"]').text().trim() ||
|
||||
$('meta[property="og:description"]').attr('content') ||
|
||||
null;
|
||||
|
||||
let price: number | null = null;
|
||||
let currency = 'USD';
|
||||
|
||||
const priceText = $('span[itemprop="price"]').first().attr('content') ||
|
||||
$('span[itemprop="price"]').first().text() ||
|
||||
$('.price-characteristic').first().text();
|
||||
|
||||
if (priceText) {
|
||||
const extracted = extractPrice(priceText);
|
||||
price = extracted.price;
|
||||
currency = extracted.currency || 'USD';
|
||||
}
|
||||
|
||||
const imageUrl = $('img[itemprop="image"]').attr('src') ||
|
||||
$('meta[property="og:image"]').attr('content') ||
|
||||
null;
|
||||
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
price,
|
||||
currency,
|
||||
imageUrl,
|
||||
url,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Best Buy-specific scraper
|
||||
*/
|
||||
function scrapeBestBuy(html: string, url: string): ScrapedData {
|
||||
const $ = cheerio.load(html);
|
||||
|
||||
const title = $('h1.heading-5').first().text().trim() ||
|
||||
$('meta[property="og:title"]').attr('content') ||
|
||||
null;
|
||||
|
||||
const description = $('div.shop-product-description').first().text().trim() ||
|
||||
$('meta[property="og:description"]').attr('content') ||
|
||||
null;
|
||||
|
||||
let price: number | null = null;
|
||||
let currency = 'USD';
|
||||
|
||||
const priceText = $('div[data-testid="customer-price"] span').first().text() ||
|
||||
$('.priceView-hero-price span').first().text();
|
||||
|
||||
if (priceText) {
|
||||
const extracted = extractPrice(priceText);
|
||||
price = extracted.price;
|
||||
currency = extracted.currency || 'USD';
|
||||
}
|
||||
|
||||
const imageUrl = $('img.primary-image').first().attr('src') ||
|
||||
$('meta[property="og:image"]').attr('content') ||
|
||||
null;
|
||||
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
price,
|
||||
currency,
|
||||
imageUrl,
|
||||
url,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Main scrape function - detects site and uses appropriate scraper
|
||||
*/
|
||||
export async function scrapeUrl(url: string): Promise<ScrapedData> {
|
||||
try {
|
||||
// Normalize URL
|
||||
const normalizedUrl = url.startsWith('http') ? url : `https://${url}`;
|
||||
const urlObj = new URL(normalizedUrl);
|
||||
const hostname = urlObj.hostname.toLowerCase();
|
||||
|
||||
// Fetch HTML
|
||||
const html = await fetchHtml(normalizedUrl);
|
||||
|
||||
// Use site-specific scraper if available
|
||||
if (hostname.includes('amazon.')) {
|
||||
return scrapeAmazon(html, normalizedUrl);
|
||||
} else if (hostname.includes('target.com')) {
|
||||
return scrapeTarget(html, normalizedUrl);
|
||||
} else if (hostname.includes('walmart.com')) {
|
||||
return scrapeWalmart(html, normalizedUrl);
|
||||
} else if (hostname.includes('bestbuy.com')) {
|
||||
return scrapeBestBuy(html, normalizedUrl);
|
||||
}
|
||||
|
||||
// Fallback to generic scraper
|
||||
return scrapeGeneric(html, normalizedUrl);
|
||||
} catch (error) {
|
||||
throw new Error(`Scraping failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user