This commit is contained in:
2026-03-27 23:33:31 -05:00
parent 9fa1df47ad
commit 6f7c038834
29 changed files with 2093 additions and 0 deletions

37
backend/Dockerfile Normal file
View File

@@ -0,0 +1,37 @@
# ── Build stage ──────────────────────────────────────────────────────────────
FROM node:20-slim AS builder
RUN apt-get update && apt-get install -y \
python3 make g++ \
--no-install-recommends \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY package*.json tsconfig.json ./
RUN npm ci
COPY src ./src
RUN npm run build && npm prune --production
# ── Runtime stage ─────────────────────────────────────────────────────────────
FROM node:20-slim
# Install Chromium and runtime dependencies
RUN apt-get update && apt-get install -y \
chromium \
fonts-freefont-ttf \
--no-install-recommends \
&& rm -rf /var/lib/apt/lists/*
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
ENV NODE_ENV=production
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY package.json ./
EXPOSE 3001
CMD ["node", "dist/index.js"]

24
backend/package.json Normal file
View File

@@ -0,0 +1,24 @@
{
"name": "ui-tracker-backend",
"version": "1.0.0",
"private": true,
"scripts": {
"build": "tsc",
"dev": "ts-node-dev --respawn --transpile-only src/index.ts",
"start": "node dist/index.js"
},
"dependencies": {
"better-sqlite3": "^9.6.0",
"cors": "^2.8.5",
"express": "^4.19.2",
"puppeteer-core": "^22.8.0"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.10",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/node": "^20.14.0",
"ts-node-dev": "^2.0.0",
"typescript": "^5.4.5"
}
}

57
backend/src/database.ts Normal file
View File

@@ -0,0 +1,57 @@
import Database from 'better-sqlite3';
import path from 'path';
const DB_PATH = process.env.DATABASE_PATH || path.join(__dirname, '../../data/tracker.db');
let db: Database.Database;
export function getDb(): Database.Database {
if (!db) {
db = new Database(DB_PATH);
db.pragma('journal_mode = WAL');
initSchema(db);
}
return db;
}
function initSchema(db: Database.Database): void {
db.exec(`
CREATE TABLE IF NOT EXISTS watched_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
url TEXT NOT NULL UNIQUE,
name TEXT,
thumbnail_url TEXT,
check_interval INTEGER NOT NULL DEFAULT 60,
is_active INTEGER NOT NULL DEFAULT 1,
last_status TEXT NOT NULL DEFAULT 'unknown',
alert_sent INTEGER NOT NULL DEFAULT 0,
check_count INTEGER NOT NULL DEFAULT 0,
last_checked_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL DEFAULT ''
);
INSERT OR IGNORE INTO settings (key, value) VALUES ('telegram_bot_token', '');
INSERT OR IGNORE INTO settings (key, value) VALUES ('telegram_chat_id', '');
`);
}
export type StockStatus = 'in_stock' | 'sold_out' | 'unknown';
export interface WatchedItem {
id: number;
url: string;
name: string | null;
thumbnail_url: string | null;
check_interval: number;
is_active: number;
last_status: StockStatus;
alert_sent: number;
check_count: number;
last_checked_at: string | null;
created_at: string;
}

27
backend/src/index.ts Normal file
View File

@@ -0,0 +1,27 @@
import express from 'express';
import cors from 'cors';
import { getDb } from './database';
import { initScheduler } from './scheduler';
import itemsRouter from './routes/items';
import settingsRouter from './routes/settings';
const app = express();
const PORT = parseInt(process.env.PORT || '3001', 10);
app.use(cors());
app.use(express.json());
app.use('/api/items', itemsRouter);
app.use('/api/settings', settingsRouter);
app.get('/api/health', (_req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// Boot sequence
getDb();
initScheduler();
app.listen(PORT, '0.0.0.0', () => {
console.log(`[Server] UI Tracker backend running on port ${PORT}`);
});

122
backend/src/routes/items.ts Normal file
View File

@@ -0,0 +1,122 @@
import { Router, Request, Response } from 'express';
import { getDb, WatchedItem } from '../database';
import { startItem, stopItem } from '../scheduler';
import { checkStockStatus } from '../scraper';
const router = Router();
// GET /api/items
router.get('/', (_req: Request, res: Response) => {
const items = getDb().prepare('SELECT * FROM watched_items ORDER BY created_at DESC').all();
res.json(items);
});
// POST /api/items
router.post('/', async (req: Request, res: Response) => {
const { url, check_interval } = req.body as { url?: string; check_interval?: number | string };
if (!url || typeof url !== 'string' || !url.startsWith('http')) {
res.status(400).json({ error: 'A valid URL is required' });
return;
}
const interval = Math.max(30, parseInt(String(check_interval ?? 60), 10) || 60);
const db = getDb();
try {
const item = db
.prepare('INSERT INTO watched_items (url, check_interval) VALUES (?, ?) RETURNING *')
.get(url.trim(), interval) as WatchedItem;
// Async initial scrape — populates name + thumbnail in background
checkStockStatus(url.trim())
.then(result => {
const updates: Record<string, string | number> = { last_status: result.status };
if (result.name) updates.name = result.name;
if (result.thumbnail) updates.thumbnail_url = result.thumbnail;
const set = Object.keys(updates).map(k => `${k} = @${k}`).join(', ');
db.prepare(`UPDATE watched_items SET ${set} WHERE id = @id`).run({ ...updates, id: item.id });
})
.catch(err => console.error('[Items] Initial scrape failed:', err));
startItem(item);
res.status(201).json(item);
} catch (err: unknown) {
const sqliteErr = err as { code?: string };
if (sqliteErr.code === 'SQLITE_CONSTRAINT_UNIQUE') {
res.status(409).json({ error: 'This URL is already being tracked' });
return;
}
console.error(err);
res.status(500).json({ error: 'Failed to add item' });
}
});
// PUT /api/items/:id
router.put('/:id', (req: Request, res: Response) => {
const id = parseInt(req.params.id, 10);
const db = getDb();
const item = db.prepare('SELECT * FROM watched_items WHERE id = ?').get(id) as WatchedItem | undefined;
if (!item) { res.status(404).json({ error: 'Item not found' }); return; }
const { check_interval, is_active } = req.body as { check_interval?: number | string; is_active?: boolean | number };
const newInterval = check_interval !== undefined
? Math.max(30, parseInt(String(check_interval), 10) || item.check_interval)
: item.check_interval;
const newActive = is_active !== undefined ? (is_active ? 1 : 0) : item.is_active;
db.prepare('UPDATE watched_items SET check_interval = ?, is_active = ? WHERE id = ?')
.run(newInterval, newActive, id);
const updated = db.prepare('SELECT * FROM watched_items WHERE id = ?').get(id) as WatchedItem;
if (newActive && !item.is_active) {
startItem(updated);
} else if (!newActive && item.is_active) {
stopItem(id);
} else if (newActive && newInterval !== item.check_interval) {
startItem(updated); // restart with new interval
}
res.json(updated);
});
// DELETE /api/items/:id
router.delete('/:id', (req: Request, res: Response) => {
const id = parseInt(req.params.id, 10);
stopItem(id);
getDb().prepare('DELETE FROM watched_items WHERE id = ?').run(id);
res.status(204).send();
});
// POST /api/items/:id/pause
router.post('/:id/pause', (req: Request, res: Response) => {
const id = parseInt(req.params.id, 10);
const db = getDb();
stopItem(id);
db.prepare('UPDATE watched_items SET is_active = 0 WHERE id = ?').run(id);
res.json(db.prepare('SELECT * FROM watched_items WHERE id = ?').get(id));
});
// POST /api/items/:id/resume
router.post('/:id/resume', (req: Request, res: Response) => {
const id = parseInt(req.params.id, 10);
const db = getDb();
db.prepare('UPDATE watched_items SET is_active = 1 WHERE id = ?').run(id);
const item = db.prepare('SELECT * FROM watched_items WHERE id = ?').get(id) as WatchedItem;
startItem(item);
res.json(item);
});
// POST /api/items/:id/reset — clears alert_sent so the next in-stock triggers a new alert
router.post('/:id/reset', (req: Request, res: Response) => {
const id = parseInt(req.params.id, 10);
const db = getDb();
db.prepare('UPDATE watched_items SET alert_sent = 0 WHERE id = ?').run(id);
res.json(db.prepare('SELECT * FROM watched_items WHERE id = ?').get(id));
});
export default router;

View File

@@ -0,0 +1,36 @@
import { Router, Request, Response } from 'express';
import { getDb } from '../database';
import { testTelegramConnection } from '../telegram';
const router = Router();
function getAllSettings(): Record<string, string> {
const rows = getDb().prepare('SELECT key, value FROM settings').all() as { key: string; value: string }[];
return Object.fromEntries(rows.map(r => [r.key, r.value]));
}
// GET /api/settings
router.get('/', (_req: Request, res: Response) => {
res.json(getAllSettings());
});
// PUT /api/settings
router.put('/', (req: Request, res: Response) => {
const { telegram_bot_token, telegram_chat_id } = req.body as Record<string, string>;
const db = getDb();
const upsert = db.prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)');
if (telegram_bot_token !== undefined) upsert.run('telegram_bot_token', telegram_bot_token);
if (telegram_chat_id !== undefined) upsert.run('telegram_chat_id', telegram_chat_id);
res.json(getAllSettings());
});
// POST /api/settings/test-telegram
router.post('/test-telegram', async (_req: Request, res: Response) => {
const result = await testTelegramConnection();
res.status(result.success ? 200 : 400).json(result);
});
export default router;

85
backend/src/scheduler.ts Normal file
View File

@@ -0,0 +1,85 @@
import { getDb, WatchedItem } from './database';
import { checkStockStatus } from './scraper';
import { sendTelegramAlert } from './telegram';
const MIN_INTERVAL_SECONDS = 30;
const timers = new Map<number, NodeJS.Timeout>();
async function runCheck(itemId: number): Promise<void> {
const db = getDb();
const item = db.prepare('SELECT * FROM watched_items WHERE id = ?').get(itemId) as WatchedItem | undefined;
if (!item || !item.is_active) return;
console.log(`[Scheduler] Checking item ${itemId}${item.name || item.url}`);
try {
const result = await checkStockStatus(item.url);
const now = new Date().toISOString();
let alertSent = item.alert_sent;
// Fire alert only on transition to in_stock when no alert has been sent yet
if (result.status === 'in_stock' && !item.alert_sent) {
const displayName = result.name || item.name || item.url;
await sendTelegramAlert(
`🟢 <b>Back in Stock!</b>\n\n` +
`<b>${displayName}</b>\n\n` +
`<a href="${item.url}">Open in Store →</a>`
);
alertSent = 1;
}
// Build update payload
const updates: Record<string, string | number> = {
last_status: result.status,
alert_sent: alertSent,
check_count: item.check_count + 1,
last_checked_at: now,
};
// Populate name and thumbnail on first successful scrape
if (result.name && !item.name) updates.name = result.name;
if (result.thumbnail && !item.thumbnail_url) updates.thumbnail_url = result.thumbnail;
const setClause = Object.keys(updates).map(k => `${k} = @${k}`).join(', ');
db.prepare(`UPDATE watched_items SET ${setClause} WHERE id = @id`).run({ ...updates, id: itemId });
console.log(`[Scheduler] Item ${itemId}${result.status} (check #${item.check_count + 1})`);
} catch (err) {
console.error(`[Scheduler] Error checking item ${itemId}:`, err);
// Still increment count so the UI shows activity
db.prepare('UPDATE watched_items SET check_count = check_count + 1, last_checked_at = ? WHERE id = ?')
.run(new Date().toISOString(), itemId);
}
}
export function startItem(item: WatchedItem): void {
stopItem(item.id);
const intervalMs = Math.max(MIN_INTERVAL_SECONDS, item.check_interval) * 1000;
const timer = setInterval(() => runCheck(item.id), intervalMs);
timers.set(item.id, timer);
console.log(`[Scheduler] Started item ${item.id} — interval ${intervalMs / 1000}s`);
}
export function stopItem(id: number): void {
const timer = timers.get(id);
if (timer) {
clearInterval(timer);
timers.delete(id);
console.log(`[Scheduler] Stopped item ${id}`);
}
}
export function initScheduler(): void {
const db = getDb();
const activeItems = db.prepare('SELECT * FROM watched_items WHERE is_active = 1').all() as WatchedItem[];
for (const item of activeItems) {
startItem(item);
}
console.log(`[Scheduler] Initialized — ${activeItems.length} active item(s)`);
}

113
backend/src/scraper.ts Normal file
View File

@@ -0,0 +1,113 @@
import puppeteer from 'puppeteer-core';
import type { StockStatus } from './database';
export interface ScrapeResult {
status: StockStatus;
name?: string;
thumbnail?: string;
}
// Limit concurrent Puppeteer instances to avoid OOM on Unraid
class Semaphore {
private permits: number;
private queue: Array<() => void> = [];
constructor(permits: number) {
this.permits = permits;
}
acquire(): Promise<void> {
if (this.permits > 0) {
this.permits--;
return Promise.resolve();
}
return new Promise(resolve => this.queue.push(resolve));
}
release(): void {
const next = this.queue.shift();
if (next) {
next();
} else {
this.permits++;
}
}
}
const scrapeSemaphore = new Semaphore(2);
export async function checkStockStatus(url: string): Promise<ScrapeResult> {
await scrapeSemaphore.acquire();
const executablePath = process.env.PUPPETEER_EXECUTABLE_PATH || '/usr/bin/chromium';
const browser = await puppeteer.launch({
executablePath,
headless: true,
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--disable-accelerated-2d-canvas',
'--disable-gpu',
'--no-first-run',
'--no-zygote',
],
});
try {
const page = await browser.newPage();
// Spoof navigator to reduce bot detection
await page.evaluateOnNewDocument(() => {
Object.defineProperty(navigator, 'webdriver', { get: () => false });
});
await page.setUserAgent(
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36'
);
await page.setViewport({ width: 1280, height: 800 });
await page.goto(url, { waitUntil: 'networkidle2', timeout: 45000 });
// Wait for React hydration
await new Promise(r => setTimeout(r, 2500));
const result = await page.evaluate(() => {
let status: 'in_stock' | 'sold_out' | 'unknown' = 'unknown';
// Check all spans for the known button text
const spans = document.querySelectorAll('span');
for (const span of spans) {
const text = span.textContent?.trim();
if (text === 'Add to Cart') { status = 'in_stock'; break; }
if (text === 'Sold Out') { status = 'sold_out'; break; }
}
// Product name: og:title is most reliable for single-product pages
let name: string | undefined;
const ogTitle = document.querySelector('meta[property="og:title"]') as HTMLMetaElement | null;
if (ogTitle?.content) {
name = ogTitle.content.replace(' Ubiquiti Store', '').replace(' | Ubiquiti Store', '').trim();
}
if (!name) {
const h1 = document.querySelector('h1');
name = h1?.textContent?.trim();
}
// Thumbnail: og:image is the most reliable
let thumbnail: string | undefined;
const ogImage = document.querySelector('meta[property="og:image"]') as HTMLMetaElement | null;
if (ogImage?.content) {
thumbnail = ogImage.content;
}
return { status, name, thumbnail };
});
return result as ScrapeResult;
} finally {
await browser.close();
scrapeSemaphore.release();
}
}

67
backend/src/telegram.ts Normal file
View File

@@ -0,0 +1,67 @@
import { getDb } from './database';
function getCredentials(): { token: string; chatId: string } | null {
const db = getDb();
const token = (db.prepare('SELECT value FROM settings WHERE key = ?').get('telegram_bot_token') as { value: string } | undefined)?.value;
const chatId = (db.prepare('SELECT value FROM settings WHERE key = ?').get('telegram_chat_id') as { value: string } | undefined)?.value;
if (!token || !chatId) return null;
return { token, chatId };
}
export async function sendTelegramAlert(message: string): Promise<void> {
const creds = getCredentials();
if (!creds) {
console.warn('[Telegram] Bot token or chat ID not configured — skipping alert');
return;
}
const url = `https://api.telegram.org/bot${creds.token}/sendMessage`;
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
chat_id: creds.chatId,
text: message,
parse_mode: 'HTML',
}),
});
if (!res.ok) {
const body = await res.text();
console.error('[Telegram] Failed to send alert:', res.status, body);
} else {
console.log('[Telegram] Alert sent successfully');
}
}
export async function testTelegramConnection(): Promise<{ success: boolean; error?: string }> {
const creds = getCredentials();
if (!creds) {
return { success: false, error: 'Bot token or chat ID is not configured.' };
}
try {
const url = `https://api.telegram.org/bot${creds.token}/sendMessage`;
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
chat_id: creds.chatId,
text: '🔔 <b>UI Stock Tracker</b> — Test notification! Alerts are working correctly.',
parse_mode: 'HTML',
}),
});
if (!res.ok) {
const body = await res.json() as { description?: string };
return { success: false, error: body.description || 'Unknown Telegram error' };
}
return { success: true };
} catch (err) {
return { success: false, error: String(err) };
}
}

18
backend/tsconfig.json Normal file
View File

@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"noUnusedLocals": false,
"noUnusedParameters": false
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}