Initial commit: Price Hunter — self-hosted price comparison engine

Complete application scaffolding with:
- Backend: Node.js + Fastify + sql.js (SQLite)
- Frontend: SvelteKit + Tailwind CSS
- Scraper engine with parallel fan-out, rate limiting, cheerio-based parsing
- Store management with CSS selector config and per-store test pages
- Docker setup for single-command deployment

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
mariosemes
2026-03-26 20:54:52 +01:00
commit e0f67d0835
47 changed files with 9181 additions and 0 deletions

174
src/server/models/store.ts Normal file
View File

@@ -0,0 +1,174 @@
import { getDatabase, saveDatabase } from '../db/connection.js';
export interface Store {
id: number;
name: string;
slug: string;
base_url: string;
search_url: string;
enabled: number;
sel_container: string;
sel_name: string;
sel_price: string;
sel_link: string;
sel_image: string | null;
rate_limit: number;
rate_window: number;
proxy_url: string | null;
user_agent: string | null;
headers_json: string | null;
currency: string;
category_id: number | null;
created_at: string;
updated_at: string;
}
export interface StoreWithCategory extends Store {
category_name: string | null;
category_color: string | null;
}
export interface CreateStoreInput {
name: string;
slug?: string;
base_url: string;
search_url: string;
sel_container: string;
sel_name: string;
sel_price: string;
sel_link: string;
sel_image?: string;
rate_limit?: number;
rate_window?: number;
proxy_url?: string;
user_agent?: string;
headers_json?: string;
currency?: string;
category_id?: number;
}
function queryAll(sql: string, params: any[] = []): any[] {
const db = getDatabase();
const stmt = db.prepare(sql);
if (params.length) stmt.bind(params);
const rows: any[] = [];
while (stmt.step()) {
rows.push(stmt.getAsObject());
}
stmt.free();
return rows;
}
function queryOne(sql: string, params: any[] = []): any | undefined {
const rows = queryAll(sql, params);
return rows[0];
}
function slugify(text: string): string {
return text
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
}
export function getAllStores(): StoreWithCategory[] {
return queryAll(`
SELECT s.*, c.name as category_name, c.color as category_color
FROM stores s
LEFT JOIN categories c ON s.category_id = c.id
ORDER BY s.name
`);
}
export function getStoreById(id: number): StoreWithCategory | undefined {
return queryOne(`
SELECT s.*, c.name as category_name, c.color as category_color
FROM stores s
LEFT JOIN categories c ON s.category_id = c.id
WHERE s.id = ?
`, [id]);
}
export function getEnabledStores(): Store[] {
return queryAll('SELECT * FROM stores WHERE enabled = 1 ORDER BY name');
}
export function getStoresByCategory(categoryId: number): Store[] {
return queryAll('SELECT * FROM stores WHERE enabled = 1 AND category_id = ? ORDER BY name', [categoryId]);
}
export function getStoresByGroup(groupId: number): Store[] {
return queryAll(`
SELECT s.* FROM stores s
JOIN store_group_members sgm ON s.id = sgm.store_id
WHERE s.enabled = 1 AND sgm.group_id = ?
ORDER BY s.name
`, [groupId]);
}
export function getStoresByIds(ids: number[]): Store[] {
if (ids.length === 0) return [];
const placeholders = ids.map(() => '?').join(',');
return queryAll(`SELECT * FROM stores WHERE enabled = 1 AND id IN (${placeholders}) ORDER BY name`, ids);
}
export function createStore(input: CreateStoreInput): Store {
const db = getDatabase();
const slug = input.slug || slugify(input.name);
db.run(`
INSERT INTO stores (name, slug, base_url, search_url, sel_container, sel_name, sel_price, sel_link, sel_image,
rate_limit, rate_window, proxy_url, user_agent, headers_json, currency, category_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, [
input.name, slug, input.base_url, input.search_url,
input.sel_container, input.sel_name, input.sel_price, input.sel_link, input.sel_image || null,
input.rate_limit ?? 2, input.rate_window ?? 1000,
input.proxy_url || null, input.user_agent || null, input.headers_json || null,
input.currency || 'EUR', input.category_id || null,
]);
const lastId = queryOne('SELECT last_insert_rowid() as id')?.id;
saveDatabase();
return getStoreById(lastId) as Store;
}
export function updateStore(id: number, input: Partial<CreateStoreInput>): Store | undefined {
const existing = getStoreById(id);
if (!existing) return undefined;
const db = getDatabase();
const fields: string[] = [];
const values: any[] = [];
for (const [key, value] of Object.entries(input)) {
if (value !== undefined) {
fields.push(`${key} = ?`);
values.push(value);
}
}
if (fields.length === 0) return existing;
fields.push("updated_at = datetime('now')");
values.push(id);
db.run(`UPDATE stores SET ${fields.join(', ')} WHERE id = ?`, values);
saveDatabase();
return getStoreById(id);
}
export function toggleStoreEnabled(id: number): Store | undefined {
const db = getDatabase();
db.run("UPDATE stores SET enabled = CASE WHEN enabled = 1 THEN 0 ELSE 1 END, updated_at = datetime('now') WHERE id = ?", [id]);
saveDatabase();
return getStoreById(id);
}
export function deleteStore(id: number): boolean {
const db = getDatabase();
db.run('DELETE FROM stores WHERE id = ?', [id]);
const changes = db.getRowsModified();
if (changes > 0) saveDatabase();
return changes > 0;
}