|
|
@@ -0,0 +1,412 @@
|
|
|
+import Database from 'better-sqlite3';
|
|
|
+import { v4 as uuidv4 } from 'uuid';
|
|
|
+import { logger } from '../utils/logger';
|
|
|
+import path from 'path';
|
|
|
+
|
|
|
+export interface Shop {
|
|
|
+ id: string; // UUID
|
|
|
+ url: string;
|
|
|
+ sitemap_url: string;
|
|
|
+ webshop_type: string;
|
|
|
+ created_at: string;
|
|
|
+ updated_at: string;
|
|
|
+}
|
|
|
+
|
|
|
+export interface ShopAnalytics {
|
|
|
+ shop_id: string;
|
|
|
+ total_scrapes: number;
|
|
|
+ last_scraped_at: string | null;
|
|
|
+ next_scrape_at: string | null;
|
|
|
+ total_urls_found: number;
|
|
|
+ average_scrape_time: number | null;
|
|
|
+ average_page_scrape_time: number | null;
|
|
|
+}
|
|
|
+
|
|
|
+export interface ScrapeHistory {
|
|
|
+ id: string;
|
|
|
+ shop_id: string;
|
|
|
+ job_id: string;
|
|
|
+ started_at: string;
|
|
|
+ completed_at: string | null;
|
|
|
+ status: 'pending' | 'processing' | 'completed' | 'failed';
|
|
|
+ error: string | null;
|
|
|
+ scrape_time_ms: number | null;
|
|
|
+ urls_found: number;
|
|
|
+}
|
|
|
+
|
|
|
+export interface ShopContent {
|
|
|
+ id: string;
|
|
|
+ shop_id: string;
|
|
|
+ content_type: 'shipping' | 'contacts' | 'terms' | 'faq';
|
|
|
+ url: string;
|
|
|
+ content: string;
|
|
|
+ content_hash: string; // MD5 hash of content to detect changes
|
|
|
+ changed: boolean; // true if content changed from previous scrape
|
|
|
+ created_at: string;
|
|
|
+ updated_at: string;
|
|
|
+}
|
|
|
+
|
|
|
+export interface ScheduledJob {
|
|
|
+ id: string;
|
|
|
+ shop_id: string;
|
|
|
+ next_run_at: string;
|
|
|
+ frequency: string | null; // from sitemap changefreq
|
|
|
+ last_modified: string | null; // from sitemap lastmod
|
|
|
+ status: 'queued' | 'running' | 'completed' | 'failed';
|
|
|
+ created_at: string;
|
|
|
+}
|
|
|
+
|
|
|
+export class ShopDatabase {
|
|
|
+ private db: Database.Database;
|
|
|
+
|
|
|
+ constructor(dbPath: string = path.join(process.cwd(), 'data', 'shops.db')) {
|
|
|
+ // Ensure data directory exists
|
|
|
+ const fs = require('fs');
|
|
|
+ const dir = path.dirname(dbPath);
|
|
|
+ if (!fs.existsSync(dir)) {
|
|
|
+ fs.mkdirSync(dir, { recursive: true });
|
|
|
+ }
|
|
|
+
|
|
|
+ this.db = new Database(dbPath);
|
|
|
+ this.initializeTables();
|
|
|
+ logger.info(`Database initialized at ${dbPath}`);
|
|
|
+ }
|
|
|
+
|
|
|
+ private initializeTables(): void {
|
|
|
+ // Shops table
|
|
|
+ this.db.exec(`
|
|
|
+ CREATE TABLE IF NOT EXISTS shops (
|
|
|
+ id TEXT PRIMARY KEY,
|
|
|
+ url TEXT NOT NULL UNIQUE,
|
|
|
+ sitemap_url TEXT NOT NULL,
|
|
|
+ webshop_type TEXT NOT NULL,
|
|
|
+ created_at TEXT NOT NULL,
|
|
|
+ updated_at TEXT NOT NULL
|
|
|
+ )
|
|
|
+ `);
|
|
|
+
|
|
|
+ // Shop analytics table
|
|
|
+ this.db.exec(`
|
|
|
+ CREATE TABLE IF NOT EXISTS shop_analytics (
|
|
|
+ shop_id TEXT PRIMARY KEY,
|
|
|
+ total_scrapes INTEGER DEFAULT 0,
|
|
|
+ last_scraped_at TEXT,
|
|
|
+ next_scrape_at TEXT,
|
|
|
+ total_urls_found INTEGER DEFAULT 0,
|
|
|
+ average_scrape_time REAL,
|
|
|
+ average_page_scrape_time REAL,
|
|
|
+ FOREIGN KEY (shop_id) REFERENCES shops(id) ON DELETE CASCADE
|
|
|
+ )
|
|
|
+ `);
|
|
|
+
|
|
|
+ // Scrape history table
|
|
|
+ this.db.exec(`
|
|
|
+ CREATE TABLE IF NOT EXISTS scrape_history (
|
|
|
+ id TEXT PRIMARY KEY,
|
|
|
+ shop_id TEXT NOT NULL,
|
|
|
+ job_id TEXT NOT NULL,
|
|
|
+ started_at TEXT NOT NULL,
|
|
|
+ completed_at TEXT,
|
|
|
+ status TEXT NOT NULL,
|
|
|
+ error TEXT,
|
|
|
+ scrape_time_ms INTEGER,
|
|
|
+ urls_found INTEGER DEFAULT 0,
|
|
|
+ FOREIGN KEY (shop_id) REFERENCES shops(id) ON DELETE CASCADE
|
|
|
+ )
|
|
|
+ `);
|
|
|
+
|
|
|
+ // Shop content table
|
|
|
+ this.db.exec(`
|
|
|
+ CREATE TABLE IF NOT EXISTS shop_content (
|
|
|
+ id TEXT PRIMARY KEY,
|
|
|
+ shop_id TEXT NOT NULL,
|
|
|
+ content_type TEXT NOT NULL,
|
|
|
+ url TEXT NOT NULL,
|
|
|
+ content TEXT NOT NULL,
|
|
|
+ content_hash TEXT NOT NULL,
|
|
|
+ changed INTEGER DEFAULT 0,
|
|
|
+ created_at TEXT NOT NULL,
|
|
|
+ updated_at TEXT NOT NULL,
|
|
|
+ FOREIGN KEY (shop_id) REFERENCES shops(id) ON DELETE CASCADE
|
|
|
+ )
|
|
|
+ `);
|
|
|
+
|
|
|
+ // Create index on shop_content for faster lookups
|
|
|
+ this.db.exec(`
|
|
|
+ CREATE INDEX IF NOT EXISTS idx_shop_content_shop_type
|
|
|
+ ON shop_content(shop_id, content_type)
|
|
|
+ `);
|
|
|
+
|
|
|
+ // Scheduled jobs table
|
|
|
+ this.db.exec(`
|
|
|
+ CREATE TABLE IF NOT EXISTS scheduled_jobs (
|
|
|
+ id TEXT PRIMARY KEY,
|
|
|
+ shop_id TEXT NOT NULL,
|
|
|
+ next_run_at TEXT NOT NULL,
|
|
|
+ frequency TEXT,
|
|
|
+ last_modified TEXT,
|
|
|
+ status TEXT NOT NULL,
|
|
|
+ created_at TEXT NOT NULL,
|
|
|
+ FOREIGN KEY (shop_id) REFERENCES shops(id) ON DELETE CASCADE
|
|
|
+ )
|
|
|
+ `);
|
|
|
+
|
|
|
+ logger.info('Database tables initialized');
|
|
|
+ }
|
|
|
+
|
|
|
+ // Shop operations
|
|
|
+ createShop(url: string, sitemapUrl: string, webshopType: string): Shop {
|
|
|
+ const id = uuidv4();
|
|
|
+ const now = new Date().toISOString();
|
|
|
+
|
|
|
+ const stmt = this.db.prepare(`
|
|
|
+ INSERT INTO shops (id, url, sitemap_url, webshop_type, created_at, updated_at)
|
|
|
+ VALUES (?, ?, ?, ?, ?, ?)
|
|
|
+ `);
|
|
|
+
|
|
|
+ stmt.run(id, url, sitemapUrl, webshopType, now, now);
|
|
|
+
|
|
|
+ // Initialize analytics for this shop
|
|
|
+ this.db.prepare(`
|
|
|
+ INSERT INTO shop_analytics (shop_id, total_scrapes, total_urls_found)
|
|
|
+ VALUES (?, 0, 0)
|
|
|
+ `).run(id);
|
|
|
+
|
|
|
+ logger.info(`Created shop ${id} for ${url}`);
|
|
|
+
|
|
|
+ return {
|
|
|
+ id,
|
|
|
+ url,
|
|
|
+ sitemap_url: sitemapUrl,
|
|
|
+ webshop_type: webshopType,
|
|
|
+ created_at: now,
|
|
|
+ updated_at: now
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ getShopByUrl(url: string): Shop | null {
|
|
|
+ const stmt = this.db.prepare('SELECT * FROM shops WHERE url = ?');
|
|
|
+ return stmt.get(url) as Shop | null;
|
|
|
+ }
|
|
|
+
|
|
|
+ getShopById(id: string): Shop | null {
|
|
|
+ const stmt = this.db.prepare('SELECT * FROM shops WHERE id = ?');
|
|
|
+ return stmt.get(id) as Shop | null;
|
|
|
+ }
|
|
|
+
|
|
|
+ getAllShops(): Shop[] {
|
|
|
+ const stmt = this.db.prepare('SELECT * FROM shops ORDER BY created_at DESC');
|
|
|
+ return stmt.all() as Shop[];
|
|
|
+ }
|
|
|
+
|
|
|
+ updateShop(id: string, updates: Partial<Shop>): void {
|
|
|
+ const now = new Date().toISOString();
|
|
|
+ const fields = Object.keys(updates).map(key => `${key} = ?`).join(', ');
|
|
|
+ const values = [...Object.values(updates), now, id];
|
|
|
+
|
|
|
+ this.db.prepare(`
|
|
|
+ UPDATE shops
|
|
|
+ SET ${fields}, updated_at = ?
|
|
|
+ WHERE id = ?
|
|
|
+ `).run(...values);
|
|
|
+ }
|
|
|
+
|
|
|
+ // Analytics operations
|
|
|
+ getShopAnalytics(shopId: string): ShopAnalytics | null {
|
|
|
+ const stmt = this.db.prepare('SELECT * FROM shop_analytics WHERE shop_id = ?');
|
|
|
+ return stmt.get(shopId) as ShopAnalytics | null;
|
|
|
+ }
|
|
|
+
|
|
|
+ updateAnalytics(shopId: string, data: {
|
|
|
+ scrapeTimeMs: number;
|
|
|
+ urlsFound: number;
|
|
|
+ pageCount: number;
|
|
|
+ }): void {
|
|
|
+ const analytics = this.getShopAnalytics(shopId);
|
|
|
+ if (!analytics) return;
|
|
|
+
|
|
|
+ const totalScrapes = analytics.total_scrapes + 1;
|
|
|
+ const totalUrls = analytics.total_urls_found + data.urlsFound;
|
|
|
+
|
|
|
+ // Calculate running average for scrape time
|
|
|
+ const avgScrapeTime = analytics.average_scrape_time
|
|
|
+ ? (analytics.average_scrape_time * analytics.total_scrapes + data.scrapeTimeMs) / totalScrapes
|
|
|
+ : data.scrapeTimeMs;
|
|
|
+
|
|
|
+ // Calculate average page scrape time
|
|
|
+ const avgPageScrapeTime = data.pageCount > 0
|
|
|
+ ? data.scrapeTimeMs / data.pageCount
|
|
|
+ : null;
|
|
|
+
|
|
|
+ this.db.prepare(`
|
|
|
+ UPDATE shop_analytics
|
|
|
+ SET total_scrapes = ?,
|
|
|
+ last_scraped_at = ?,
|
|
|
+ total_urls_found = ?,
|
|
|
+ average_scrape_time = ?,
|
|
|
+ average_page_scrape_time = ?
|
|
|
+ WHERE shop_id = ?
|
|
|
+ `).run(
|
|
|
+ totalScrapes,
|
|
|
+ new Date().toISOString(),
|
|
|
+ totalUrls,
|
|
|
+ avgScrapeTime,
|
|
|
+ avgPageScrapeTime,
|
|
|
+ shopId
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ setNextScrapeTime(shopId: string, nextScrapeAt: string): void {
|
|
|
+ this.db.prepare(`
|
|
|
+ UPDATE shop_analytics
|
|
|
+ SET next_scrape_at = ?
|
|
|
+ WHERE shop_id = ?
|
|
|
+ `).run(nextScrapeAt, shopId);
|
|
|
+ }
|
|
|
+
|
|
|
+ // Scrape history operations
|
|
|
+ createScrapeHistory(shopId: string, jobId: string): string {
|
|
|
+ const id = uuidv4();
|
|
|
+ const now = new Date().toISOString();
|
|
|
+
|
|
|
+ this.db.prepare(`
|
|
|
+ INSERT INTO scrape_history (id, shop_id, job_id, started_at, status, urls_found)
|
|
|
+ VALUES (?, ?, ?, ?, 'pending', 0)
|
|
|
+ `).run(id, shopId, jobId, now);
|
|
|
+
|
|
|
+ return id;
|
|
|
+ }
|
|
|
+
|
|
|
+ updateScrapeHistory(id: string, updates: Partial<ScrapeHistory>): void {
|
|
|
+ const fields = Object.keys(updates).map(key => `${key} = ?`).join(', ');
|
|
|
+ const values = [...Object.values(updates), id];
|
|
|
+
|
|
|
+ this.db.prepare(`
|
|
|
+ UPDATE scrape_history
|
|
|
+ SET ${fields}
|
|
|
+ WHERE id = ?
|
|
|
+ `).run(...values);
|
|
|
+ }
|
|
|
+
|
|
|
+ getScrapeHistory(shopId: string, limit: number = 10): ScrapeHistory[] {
|
|
|
+ const stmt = this.db.prepare(`
|
|
|
+ SELECT * FROM scrape_history
|
|
|
+ WHERE shop_id = ?
|
|
|
+ ORDER BY started_at DESC
|
|
|
+ LIMIT ?
|
|
|
+ `);
|
|
|
+ return stmt.all(shopId, limit) as ScrapeHistory[];
|
|
|
+ }
|
|
|
+
|
|
|
+ // Content operations
|
|
|
+ saveContent(
|
|
|
+ shopId: string,
|
|
|
+ contentType: 'shipping' | 'contacts' | 'terms' | 'faq',
|
|
|
+ url: string,
|
|
|
+ content: string
|
|
|
+ ): { changed: boolean; contentId: string } {
|
|
|
+ const crypto = require('crypto');
|
|
|
+ const contentHash = crypto.createHash('md5').update(content).digest('hex');
|
|
|
+ const now = new Date().toISOString();
|
|
|
+
|
|
|
+ // Check if content exists and get previous hash
|
|
|
+ const existing = this.db.prepare(`
|
|
|
+ SELECT id, content_hash FROM shop_content
|
|
|
+ WHERE shop_id = ? AND content_type = ?
|
|
|
+ ORDER BY created_at DESC
|
|
|
+ LIMIT 1
|
|
|
+ `).get(shopId, contentType) as { id: string; content_hash: string } | undefined;
|
|
|
+
|
|
|
+ const changed = existing ? existing.content_hash !== contentHash : false;
|
|
|
+ const id = uuidv4();
|
|
|
+
|
|
|
+ // Insert new content record
|
|
|
+ this.db.prepare(`
|
|
|
+ INSERT INTO shop_content (id, shop_id, content_type, url, content, content_hash, changed, created_at, updated_at)
|
|
|
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
|
+ `).run(id, shopId, contentType, url, content, contentHash, changed ? 1 : 0, now, now);
|
|
|
+
|
|
|
+ if (changed) {
|
|
|
+ logger.info(`Content changed detected for ${contentType} in shop ${shopId}`);
|
|
|
+ }
|
|
|
+
|
|
|
+ return { changed, contentId: id };
|
|
|
+ }
|
|
|
+
|
|
|
+ getLatestContent(shopId: string): { [key: string]: ShopContent } {
|
|
|
+ const stmt = this.db.prepare(`
|
|
|
+ SELECT * FROM (
|
|
|
+ SELECT *, ROW_NUMBER() OVER (PARTITION BY content_type ORDER BY created_at DESC) as rn
|
|
|
+ FROM shop_content
|
|
|
+ WHERE shop_id = ?
|
|
|
+ ) WHERE rn = 1
|
|
|
+ `);
|
|
|
+
|
|
|
+ const rows = stmt.all(shopId) as ShopContent[];
|
|
|
+ const result: { [key: string]: ShopContent } = {};
|
|
|
+
|
|
|
+ for (const row of rows) {
|
|
|
+ result[row.content_type] = row;
|
|
|
+ }
|
|
|
+
|
|
|
+ return result;
|
|
|
+ }
|
|
|
+
|
|
|
+ getContentHistory(shopId: string, contentType: string, limit: number = 5): ShopContent[] {
|
|
|
+ const stmt = this.db.prepare(`
|
|
|
+ SELECT * FROM shop_content
|
|
|
+ WHERE shop_id = ? AND content_type = ?
|
|
|
+ ORDER BY created_at DESC
|
|
|
+ LIMIT ?
|
|
|
+ `);
|
|
|
+ return stmt.all(shopId, contentType, limit) as ShopContent[];
|
|
|
+ }
|
|
|
+
|
|
|
+ // Scheduled jobs operations
|
|
|
+ createScheduledJob(shopId: string, nextRunAt: string, frequency: string | null, lastModified: string | null): string {
|
|
|
+ const id = uuidv4();
|
|
|
+ const now = new Date().toISOString();
|
|
|
+
|
|
|
+ this.db.prepare(`
|
|
|
+ INSERT INTO scheduled_jobs (id, shop_id, next_run_at, frequency, last_modified, status, created_at)
|
|
|
+ VALUES (?, ?, ?, ?, ?, 'queued', ?)
|
|
|
+ `).run(id, shopId, nextRunAt, frequency, lastModified, now);
|
|
|
+
|
|
|
+ return id;
|
|
|
+ }
|
|
|
+
|
|
|
+ getScheduledJobs(shopId: string): ScheduledJob[] {
|
|
|
+ const stmt = this.db.prepare(`
|
|
|
+ SELECT * FROM scheduled_jobs
|
|
|
+ WHERE shop_id = ?
|
|
|
+ ORDER BY next_run_at ASC
|
|
|
+ `);
|
|
|
+ return stmt.all(shopId) as ScheduledJob[];
|
|
|
+ }
|
|
|
+
|
|
|
+ getJobsDueForExecution(): ScheduledJob[] {
|
|
|
+ const now = new Date().toISOString();
|
|
|
+ const stmt = this.db.prepare(`
|
|
|
+ SELECT * FROM scheduled_jobs
|
|
|
+ WHERE next_run_at <= ? AND status = 'queued'
|
|
|
+ ORDER BY next_run_at ASC
|
|
|
+ `);
|
|
|
+ return stmt.all(now) as ScheduledJob[];
|
|
|
+ }
|
|
|
+
|
|
|
+ updateScheduledJob(id: string, updates: Partial<ScheduledJob>): void {
|
|
|
+ const fields = Object.keys(updates).map(key => `${key} = ?`).join(', ');
|
|
|
+ const values = [...Object.values(updates), id];
|
|
|
+
|
|
|
+ this.db.prepare(`
|
|
|
+ UPDATE scheduled_jobs
|
|
|
+ SET ${fields}
|
|
|
+ WHERE id = ?
|
|
|
+ `).run(...values);
|
|
|
+ }
|
|
|
+
|
|
|
+ close(): void {
|
|
|
+ this.db.close();
|
|
|
+ logger.info('Database connection closed');
|
|
|
+ }
|
|
|
+}
|