Initialer Versionstand des eBay Trackers

This commit is contained in:
2026-08-01 00:37:02 +00:00
commit af754182fa
12 changed files with 1878 additions and 0 deletions

8
.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
# Passwörter und API-Keys ignorieren
.env
# Dependencies / Pakete ignorieren
node_modules/
# Logs ignorieren
*.log

14
Dockerfile Normal file
View File

@@ -0,0 +1,14 @@
FROM node:20-alpine
WORKDIR /app
# Dependencies installieren
COPY package*.json ./
RUN npm ci --only=production
# Sourcecode kopieren
COPY . .
EXPOSE 3001
CMD ["npm", "start"]

11
docker-compose.yml Normal file
View File

@@ -0,0 +1,11 @@
version: '3.8'
services:
ebay-tracker:
build: .
container_name: ebay-tracker
restart: unless-stopped
ports:
- "3001:3001"
env_file:
- .env

1174
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

15
package.json Normal file
View File

@@ -0,0 +1,15 @@
{
"name": "ebay-tracker",
"version": "1.1.0",
"type": "module",
"main": "src/server.js",
"scripts": {
"start": "node src/server.js"
},
"dependencies": {
"axios": "^1.7.2",
"dotenv": "^16.4.5",
"express": "^4.19.2",
"pg": "^8.12.0"
}
}

84
src/db.js Normal file
View File

@@ -0,0 +1,84 @@
import pg from 'pg';
import dotenv from 'dotenv';
dotenv.config();
const { Pool } = pg;
export const pool = new Pool({
host: process.env.PGHOST,
port: parseInt(process.env.PGPORT || '5432'),
database: process.env.PGDATABASE,
user: process.env.PGUSER,
password: process.env.PGPASSWORD,
connectionTimeoutMillis: 5000,
});
// UTF-8 erzwingen
pool.on('connect', (client) => {
client.query("SET client_encoding TO 'UTF8'");
});
export async function initDb() {
const client = await pool.connect();
try {
console.log('🔄 Prüfe und erstelle Datenbank-Tabellen...');
// 1. Tabelle für Verkäufer-Whitelist
await client.query(`
CREATE TABLE IF NOT EXISTS sellers (
id SERIAL PRIMARY KEY,
username VARCHAR(255) UNIQUE NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
`);
// 2. Tabelle für Telegram-Suchaufträge
await client.query(`
CREATE TABLE IF NOT EXISTS tracked_searches (
id SERIAL PRIMARY KEY,
chat_id VARCHAR(255) NOT NULL,
query TEXT NOT NULL,
negative_keywords TEXT[] DEFAULT '{}',
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
`);
// 3. Tabelle für Artikel-Snapshots (Preisverlauf & Alerts)
await client.query(`
CREATE TABLE IF NOT EXISTS item_snapshots (
id SERIAL PRIMARY KEY,
item_id VARCHAR(255) UNIQUE NOT NULL,
search_id INTEGER REFERENCES tracked_searches(id) ON DELETE CASCADE,
seller_username VARCHAR(255),
title TEXT NOT NULL,
price NUMERIC(10, 2) NOT NULL,
currency VARCHAR(10) DEFAULT 'EUR',
item_url TEXT,
image_url TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
last_updated TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
`);
// In initDb() ergänzen:
await client.query(`
ALTER TABLE item_snapshots ADD COLUMN IF NOT EXISTS lowest_price NUMERIC(10, 2);
CREATE TABLE IF NOT EXISTS price_history (
id SERIAL PRIMARY KEY,
item_id VARCHAR(255) NOT NULL,
price NUMERIC(10, 2) NOT NULL,
recorded_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
`);
console.log('✅ PostgreSQL Tabellen erfolgreich initialisiert (UTF-8).');
} catch (error) {
console.error('❌ Fehler bei der Tabellen-Initialisierung:', error.message);
throw error;
} finally {
client.release();
}
}

45
src/ebayAuth.js Normal file
View File

@@ -0,0 +1,45 @@
import axios from 'axios';
import dotenv from 'dotenv';
dotenv.config();
let tokenCache = {
token: null,
expiresAt: 0
};
export async function getEbayToken() {
const now = Date.now();
if (tokenCache.token && now < tokenCache.expiresAt - 5 * 60 * 1000) {
return tokenCache.token;
}
console.log('🔄 Hole neuen eBay OAuth Access Token...');
const credentials = Buffer.from(
`${process.env.EBAY_APP_ID}:${process.env.EBAY_CERT_ID}`
).toString('base64');
try {
const response = await axios.post(
'https://api.ebay.com/identity/v1/oauth2/token',
'grant_type=client_credentials&scope=https://api.ebay.com/oauth/api_scope',
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Authorization': `Basic ${credentials}`
}
}
);
tokenCache.token = response.data.access_token;
tokenCache.expiresAt = now + (response.data.expires_in * 1000);
console.log('✅ eBay Token erfolgreich erneuert!');
return tokenCache.token;
} catch (error) {
console.error('❌ eBay Auth Fehler:', error.response?.data || error.message);
throw new Error('eBay Authentifizierung fehlgeschlagen.');
}
}

86
src/ebayService.js Normal file
View File

@@ -0,0 +1,86 @@
import axios from 'axios';
import { getEbayToken } from './ebayAuth.js';
// Hilfsfunktion: Aufteilen eines Arrays in kleinere Häppchen (Chunks)
function chunkArray(array, chunkSize) {
const chunks = [];
for (let i = 0; i < array.length; i += chunkSize) {
chunks.push(array.slice(i, i + chunkSize));
}
return chunks;
}
/**
* Durchsucht eBay nach ALLEN Whitelist-Verkäufern in der Datenbank
*/
export async function searchEbayProducts(query, allowedSellers = [], negativeKeywords = []) {
if (!allowedSellers || allowedSellers.length === 0) {
console.log('⚠️ Keine Verkäufer in der Whitelist hinterlegt.');
return [];
}
try {
const token = await getEbayToken();
// 1. Verkäufernamen bereinigen
const validSellers = allowedSellers
.map(s => String(s).trim())
.filter(Boolean);
if (validSellers.length === 0) return [];
// 2. Verkäufer in 25er-Pakete aufteilen
const sellerBatches = chunkArray(validSellers, 25);
let allRawItems = [];
// 3. Jedes 25er-Paket nacheinander bei eBay abfragen
for (const batch of sellerBatches) {
const formattedSellers = batch.map(s => encodeURIComponent(s)).join('|');
const sellerFilterString = `sellers:{${formattedSellers}}`;
const response = await axios.get('https://api.ebay.com/buy/browse/v1/item_summary/search', {
headers: {
'Authorization': `Bearer ${token}`,
'X-EBAY-C-MARKETPLACE-ID': 'EBAY-DE'
},
params: {
q: query,
filter: sellerFilterString,
limit: 50,
sort: 'newlyListed'
}
});
const items = response.data.itemSummaries || [];
allRawItems = allRawItems.concat(items);
}
// 4. Duplikate über die itemId entfernen
const uniqueItemsMap = new Map();
for (const item of allRawItems) {
uniqueItemsMap.set(item.itemId, item);
}
const uniqueItems = Array.from(uniqueItemsMap.values());
// 5. Negativ-Keywords lokal ausfiltern
const filteredItems = uniqueItems.filter(item => {
const titleLower = item.title.toLowerCase();
return !negativeKeywords.some(kw => titleLower.includes(kw.toLowerCase().trim()));
});
return filteredItems.map(item => ({
itemId: item.itemId,
title: item.title,
price: item.price?.value,
currency: item.price?.currency || 'EUR',
sellerUsername: item.seller?.username || 'Unbekannt',
itemUrl: item.itemWebUrl,
imageUrl: item.image?.imageUrl || null
}));
} catch (error) {
const apiError = error.response?.data?.errors?.[0]?.message || error.message;
console.error(`❌ Fehler bei der eBay-Suche für "${query}":`, apiError);
return [];
}
}

47
src/ebayService_BK_1.js Normal file
View File

@@ -0,0 +1,47 @@
import axios from 'axios';
import { getEbayToken } from './ebayAuth.js';
export async function searchEbayProducts(query, negativeKeywords = [], sellersWhitelist = []) {
const token = await getEbayToken();
let filterParam = '';
if (sellersWhitelist.length > 0) {
filterParam = `sellers:{${sellersWhitelist.join('|')}}`;
}
try {
const response = await axios.get('https://api.ebay.com/buy/browse/v1/item_summary/search', {
headers: {
'Authorization': `Bearer ${token}`,
'X-EBAY-C-MARKETPLACE-ID': 'EBAY-DE'
},
params: {
q: query,
filter: filterParam || undefined,
limit: 50
}
});
const rawItems = response.data.itemSummaries || [];
const filteredItems = rawItems.filter(item => {
const titleLower = item.title.toLowerCase();
if (!negativeKeywords || negativeKeywords.length === 0) return true;
return !negativeKeywords.some(neg => titleLower.includes(neg.toLowerCase().trim()));
});
return filteredItems.map(item => ({
itemId: item.itemId,
title: item.title,
price: parseFloat(item.price?.value || 0),
currency: item.price?.currency || 'EUR',
sellerUsername: item.seller?.username || 'Unbekannt',
itemUrl: item.itemWebUrl,
imageUrl: item.image?.imageUrl || null
}));
} catch (error) {
console.error(`❌ eBay API Fehler für Query "${query}":`, error.response?.data || error.message);
return [];
}
}

169
src/server.js Normal file
View File

@@ -0,0 +1,169 @@
import express from 'express';
import dotenv from 'dotenv';
import { initDb, pool } from './db.js';
import { runTrackingCycle } from './tracker.js';
import { searchEbayProducts } from './ebayService.js';
dotenv.config();
const app = express();
app.use(express.json());
const PORT = process.env.PORT || 3001;
// Hilfsfunktion: Repariert doppelt-kodierte Umlaute (Mojibake)
function fixEncoding(str) {
if (!str || typeof str !== 'string') return str;
// Wenn der String typische Mojibake-Zeichen (wie ü für ü) enthält
if (str.includes('Ã')) {
try {
return Buffer.from(str, 'latin1').toString('utf-8');
} catch (e) {
return str;
}
}
return str;
}
// Healthcheck
app.get('/health', (req, res) => res.json({ status: 'ok', timestamp: new Date() }));
// Endpunkt 1: Suchauftrag aus Telegram / n8n in Postgres speichern
app.post('/searches/add', async (req, res) => {
const { chatId, query, negativeKeywords } = req.body;
if (!chatId || !query) {
return res.status(400).json({ error: 'chatId und query sind Pflichtfelder.' });
}
// 🛠️ FIX: Robustes Parsing für negativeKeywords
let parsedKeywords = [];
if (Array.isArray(negativeKeywords)) {
// Bereits ein echtes JS-Array
parsedKeywords = negativeKeywords;
} else if (typeof negativeKeywords === 'string') {
const trimmed = negativeKeywords.trim();
if (trimmed.startsWith('[')) {
// Wurde als JSON-String gesendet: ["a", "b"]
try {
parsedKeywords = JSON.parse(trimmed);
} catch (e) {
parsedKeywords = [];
}
} else if (trimmed.length > 0) {
// Wurde als Komma-getrennter String gesendet: "controller, headset"
parsedKeywords = trimmed.split(',').map(item => item.trim());
}
}
try {
const result = await pool.query(
`INSERT INTO tracked_searches (chat_id, query, negative_keywords)
VALUES ($1, $2, $3) RETURNING id`,
[chatId, query, parsedKeywords] // 👈 Hier wird das saubere JS-Array übergeben
);
res.status(201).json({
success: true,
message: 'Suchauftrag erfolgreich gespeichert.',
searchId: result.rows[0].id
});
} catch (error) {
console.error('Fehler beim Speichern der Suche:', error.message);
res.status(500).json({ error: 'Datenbankfehler beim Speichern.' });
}
});
// Endpunkt: Statische Liste von Verkäufern hinzufügen (unterstützt Arrays, Komma- & Zeilenumbrüche)
app.post('/sellers/add', async (req, res) => {
const { sellers, username } = req.body;
// Akzeptiert sowohl "sellers" (Liste) als auch "username" (Einzelwert)
const rawInput = sellers || username;
if (!rawInput) {
return res.status(400).json({ error: 'Bitte gib mindestens einen Verkäufer an.' });
}
let sellerList = [];
if (Array.isArray(rawInput)) {
// Falls n8n ein JSON-Array schickt: ["shop1", "shop2"]
sellerList = rawInput;
} else if (typeof rawInput === 'string') {
// Trennt bei Kommas ODER Zeilenumbrüchen (\n)
sellerList = rawInput.split(/[\n,]+/).map(s => s.trim()).filter(Boolean);
}
if (sellerList.length === 0) {
return res.status(400).json({ error: 'Keine gültigen Verkäufernamen gefunden.' });
}
try {
const newlyAdded = [];
for (const name of sellerList) {
const cleanName = fixEncoding(name)
const result = await pool.query(
`INSERT INTO sellers (username) VALUES ($1) ON CONFLICT (username) DO NOTHING RETURNING username`,
[cleanName]
);
if (result.rows.length > 0) {
newlyAdded.push(name);
}
}
res.json({
success: true,
message: `${sellerList.length} Verkäufer verarbeitet (${newlyAdded.length} neu hinzugefügt).`,
totalProcessed: sellerList.length,
newlyAdded: newlyAdded
});
} catch (error) {
console.error('Fehler beim Speichern der Verkäufer:', error.message);
res.status(500).json({ error: 'Datenbankfehler.' });
}
});
// Endpunkt 3: Der /scrape Endpunkt für n8n (Schedule Trigger)
app.post('/scrape', async (req, res) => {
const { webhookUrl } = req.body; // Optional: Kann in n8n dynamisch mitgegeben werden
// Sofort 202 Accepted melden, damit n8n nicht in HTTP-Timeouts rennt
res.status(202).json({
message: 'Scrape-Job akzeptiert und wird im Hintergrund ausgeführt.'
});
// Scraping asynchron ausführen
runTrackingCycle(webhookUrl).catch(err => {
console.error('Hintergrund-Fehler beim Scrapen:', err.message);
});
});
// Endpunkt für n8n (Schedule): Führt den Scraper aus & gibt Preisänderungen zurück
app.post('/tracker/run', async (req, res) => {
try {
const changes = await runTrackingCycle();
res.json({
success: true,
count: changes.length,
priceChanges: changes
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Server-Start
async function startServer() {
await initDb();
app.listen(PORT, '0.0.0.0', () => {
console.log(`🚀 eBay-Tracker Server läuft auf Port ${PORT} (Steuerung via n8n)`);
});
}
startServer();

115
src/tracker.js Normal file
View File

@@ -0,0 +1,115 @@
import { pool } from './db.js';
import { searchEbayProducts } from './ebayService.js';
export async function runTrackingCycle() {
console.log('🚀 Starte eBay-Tracking-Durchlauf mit Historien-Vergleich...');
const notifications = [];
try {
const sellersResult = await pool.query('SELECT username FROM sellers');
const allowedSellers = sellersResult.rows.map(row => row.username);
if (allowedSellers.length === 0) return notifications;
const searchesResult = await pool.query('SELECT * FROM tracked_searches WHERE is_active = TRUE');
const searches = searchesResult.rows;
for (const search of searches) {
const foundItems = await searchEbayProducts(
search.query,
allowedSellers,
search.negative_keywords || []
);
for (const item of foundItems) {
const newPrice = parseFloat(item.price);
// 1. Artikel in der DB suchen (Preis + bisherigen Tiefstpreis abfragen)
const existing = await pool.query(
'SELECT price, lowest_price FROM item_snapshots WHERE item_id = $1',
[item.itemId]
);
if (existing.rows.length === 0) {
// 🆕 BRANDNEUER ARTIKEL
notifications.push({
type: 'NEW',
title: item.title,
oldPrice: null,
newPrice: newPrice.toFixed(2),
lowestPrice: newPrice.toFixed(2),
isAllTimeLow: true,
diff: 'NEU',
seller: item.sellerUsername,
itemUrl: item.itemUrl
});
// In Preishistorie eintragen
await pool.query(
'INSERT INTO price_history (item_id, price) VALUES ($1, $2)',
[item.itemId, newPrice]
);
} else {
// 🏷️ BEREITS BEKANNTER ARTIKEL
const oldPrice = parseFloat(existing.rows[0].price);
const currentLowest = existing.rows[0].lowest_price
? parseFloat(existing.rows[0].lowest_price)
: oldPrice;
if (oldPrice !== newPrice) {
const diffVal = newPrice - oldPrice;
const diffStr = diffVal > 0 ? `+${diffVal.toFixed(2)}` : `${diffVal.toFixed(2)}`;
const isAllTimeLow = newPrice < currentLowest;
const newLowest = Math.min(currentLowest, newPrice);
notifications.push({
type: 'PRICE_CHANGE',
title: item.title,
oldPrice: oldPrice.toFixed(2),
newPrice: newPrice.toFixed(2),
lowestPrice: newLowest.toFixed(2),
isAllTimeLow: isAllTimeLow,
diff: diffStr,
seller: item.sellerUsername,
itemUrl: item.itemUrl
});
// Preisänderung in Historie-Tabelle speichern
await pool.query(
'INSERT INTO price_history (item_id, price) VALUES ($1, $2)',
[item.itemId, newPrice]
);
}
}
// 2. Snapshot aktualisieren (LEAST stellt sicher, dass der lowest_price nie überschrieben wird)
await pool.query(`
INSERT INTO item_snapshots (
item_id, search_id, seller_username, title, price, lowest_price, currency, item_url, image_url
)
VALUES ($1, $2, $3, $4, $5, $5, $6, $7, $8)
ON CONFLICT (item_id) DO UPDATE
SET price = EXCLUDED.price,
lowest_price = LEAST(item_snapshots.lowest_price, EXCLUDED.price),
last_updated = CURRENT_TIMESTAMP
`, [
item.itemId,
search.id,
item.sellerUsername,
item.title,
item.price,
item.currency,
item.itemUrl,
item.imageUrl
]);
}
}
return notifications;
} catch (error) {
console.error('❌ Fehler während des Tracking-Durchlaufs:', error.message);
return notifications;
}
}

110
src/tracker_BK_1.js Normal file
View File

@@ -0,0 +1,110 @@
import axios from 'axios';
import { pool } from './db.js';
import { searchEbayProducts } from './ebayService.js';
export async function runTrackingCycle(targetWebhookUrl) {
console.log(`\n🕒 [${new Date().toISOString()}] Starte Tracking-Durchlauf via n8n Trigger...`);
const webhookUrl = targetWebhookUrl || process.env.N8N_ALERT_WEBHOOK_URL;
try {
// 1. Alle aktiven Suchaufträge aus Postgres holen
const searchesRes = await pool.query('SELECT * FROM tracked_searches WHERE is_active = TRUE');
const searches = searchesRes.rows;
// 2. Verkäufer-Whitelist aus Postgres holen
const sellersRes = await pool.query('SELECT username FROM sellers');
const sellerList = sellersRes.rows.map(r => r.username);
if (searches.length === 0) {
console.log('ℹ️ Keine aktiven Suchen in der Datenbank.');
return { status: 'success', processedSearches: 0, alertsFound: 0 };
}
const alertsToSend = [];
for (const search of searches) {
console.log(`🔎 Prüfe Suche ID ${search.id}: "${search.query}" (Chat: ${search.chat_id})...`);
const currentItems = await searchEbayProducts(
search.query,
search.negative_keywords,
sellerList
);
for (const item of currentItems) {
// Prüfen, ob Artikel bereits in Postgres existiert
const existingRes = await pool.query(
'SELECT price FROM item_snapshots WHERE item_id = $1',
[item.itemId]
);
if (existingRes.rows.length === 0) {
// NEUER ARTIKEL
await pool.query(`
INSERT INTO item_snapshots (item_id, search_id, seller_username, title, price, currency, item_url, image_url)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
`, [item.itemId, search.id, item.sellerUsername, item.title, item.price, item.currency, item.itemUrl, item.imageUrl]);
alertsToSend.push({
type: 'NEW_ITEM',
chatId: search.chat_id,
searchQuery: search.query,
title: item.title,
newPrice: item.price,
oldPrice: null,
currency: item.currency,
seller: item.sellerUsername,
url: item.itemUrl,
imageUrl: item.imageUrl
});
} else {
const oldPrice = parseFloat(existingRes.rows[0].price);
if (item.price < oldPrice) {
// PREISSTURZ
await pool.query(`
UPDATE item_snapshots
SET price = $1, last_updated = CURRENT_TIMESTAMP
WHERE item_id = $2
`, [item.price, item.itemId]);
alertsToSend.push({
type: 'PRICE_DROP',
chatId: search.chat_id,
searchQuery: search.query,
title: item.title,
newPrice: item.price,
oldPrice: oldPrice,
currency: item.currency,
seller: item.sellerUsername,
url: item.itemUrl,
imageUrl: item.imageUrl
});
} else {
// Unverändert: Timestamp aktualisieren
await pool.query('UPDATE item_snapshots SET last_updated = CURRENT_TIMESTAMP WHERE item_id = $1', [item.itemId]);
}
}
}
}
// 3. Alerts per Webhook an n8n senden
if (alertsToSend.length > 0 && webhookUrl) {
console.log(`🚀 Sende ${alertsToSend.length} Alerts an n8n (${webhookUrl})...`);
await axios.post(webhookUrl, { alerts: alertsToSend });
}
return {
status: 'success',
processedSearches: searches.length,
alertsFound: alertsToSend.length
};
} catch (error) {
console.error('❌ Fehler im Tracking-Durchlauf:', error.message);
throw error;
}
}