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>
175 lines
4.8 KiB
TypeScript
175 lines
4.8 KiB
TypeScript
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;
|
|
}
|