diff --git a/Dockerfile b/Dockerfile index 37b4358..219ed1d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -40,5 +40,10 @@ ENV TZ=UTC EXPOSE 3000 -# Автоматически применить миграции Prisma перед запуском приложения -CMD ["sh", "-c", "npx prisma migrate deploy && node dist/index.js"] \ No newline at end of file +# Copy startup script that runs prisma generate + migrate deploy before starting the app. +# NOTE: prisma migrate deploy MUST run at startup (not during build) because it needs +# a running database. The database is only available at runtime via docker-compose. +COPY docker-entrypoint.sh /app/docker-entrypoint.sh +RUN chmod +x /app/docker-entrypoint.sh + +CMD ["/app/docker-entrypoint.sh"] diff --git a/docker-compose.yaml b/docker-compose.yaml index 94df107..9d7dc46 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -21,7 +21,7 @@ services: - HOST=0.0.0.0 - TZ=Europe/Moscow - PLAYWRIGHT_EXECUTABLE_PATH=/usr/bin/chromium-browser - - DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/telegram-scrapper + - DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB} - START_ON_BOOT=true - START_ON_BOOT_DELAY_MS=10000 - START_ON_BOOT_ATTEMPTS=5 @@ -45,7 +45,6 @@ services: - "5432:5432" volumes: - ${DATA_DIR}/pg:/var/lib/postgresql - - ./init-db:/docker-entrypoint-initdb.d healthcheck: test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] interval: 5s diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100755 index 0000000..c781337 --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,16 @@ +#!/bin/sh +set -e + +echo "=== Starting TgCrawler ===" + +# Generate Prisma client (ensures client matches current schema) +echo "Running prisma generate..." +npx prisma generate || echo "WARNING: prisma generate failed, using build-time generated client" + +# Apply database migrations (requires DATABASE_URL and running database) +echo "Running prisma migrate deploy..." +npx prisma migrate deploy || echo "WARNING: prisma migrate deploy failed, app will create tables via SQL fallback" + +# Start the application +echo "Starting application..." +exec node dist/index.js diff --git a/package-lock.json b/package-lock.json index 246b024..189492c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -67,8 +67,7 @@ "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.3.15.tgz", "integrity": "sha512-Cj++n1Mekf9ETfdc16TlDi+cDDQF0W7EcbyRHYOAeZdsAe8M/FJg18itDTSwyHfar2WIezawM9o0EKaRGVKygQ==", "devOptional": true, - "license": "Apache-2.0", - "peer": true + "license": "Apache-2.0" }, "node_modules/@electric-sql/pglite-socket": { "version": "0.0.20", @@ -992,7 +991,8 @@ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "devOptional": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/deepmerge-ts": { "version": "7.1.5", @@ -1318,7 +1318,6 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.4.tgz", "integrity": "sha512-U7tt8JsyrxSRKspfhtLET79pU8K+tInj5QZXs1jSugO1Vq5dFj3kmZsRldo29mTBfcjDRVRXrEZ6LS63Cog9ZA==", "license": "MIT", - "peer": true, "engines": { "node": ">=16.9.0" } @@ -1617,7 +1616,6 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.17.2.tgz", "integrity": "sha512-vjbKdiBJRqzcYw1fNU5KuHyYvdJ1qpcQg1CeBrHFqV1pWgHeVR6j/+kX0E1AAXfyuLUGY1ICrN2ELKA/z2HWzw==", "license": "MIT", - "peer": true, "dependencies": { "pg-connection-string": "^2.10.1", "pg-pool": "^3.11.0", @@ -1813,7 +1811,6 @@ "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@prisma/config": "7.3.0", "@prisma/dev": "0.20.0", @@ -1975,7 +1972,8 @@ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "devOptional": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/seq-queue": { "version": "0.0.5", @@ -2096,7 +2094,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/prisma/migrations/20260125085858_init/migration.sql b/prisma/migrations/20260125085858_init/migration.sql index 9388760..fd5ee45 100644 --- a/prisma/migrations/20260125085858_init/migration.sql +++ b/prisma/migrations/20260125085858_init/migration.sql @@ -14,3 +14,6 @@ CREATE TABLE "Task" ( CONSTRAINT "Task_pkey" PRIMARY KEY ("id") ); + +-- CreateIndex +CREATE UNIQUE INDEX "Task_id_post_key" ON "Task"("id_post"); diff --git a/src/crawler/crawl-only.ts b/src/crawler/crawl-only.ts index 2f7590b..1ab5b43 100644 --- a/src/crawler/crawl-only.ts +++ b/src/crawler/crawl-only.ts @@ -4,22 +4,59 @@ import { CONFIG } from "../config/config"; import { Logger } from "../log/logger"; import { ServiceFactory } from "../services/factory"; import { launchBrowser } from "../launchBrowser"; +import { TaskService } from "../services/database/task"; +import { config } from 'dotenv'; + +config(); async function crawl(): Promise { + // Parse --date argument (e.g. --date 2025-01-15) + const dateArgIndex = process.argv.indexOf('--date'); + let sinceDate: Date | undefined; + if (dateArgIndex !== -1 && process.argv[dateArgIndex + 1]) { + const parsed = new Date(process.argv[dateArgIndex + 1]); + if (isNaN(parsed.getTime())) { + Logger.error(`Invalid date: ${process.argv[dateArgIndex + 1]}. Use ISO format (e.g. 2025-01-15)`); + process.exit(1); + } + sinceDate = parsed; + } + const browser = await launchBrowser(); const scraper = ServiceFactory.createScraper(); const storage = new DataStorage(CONFIG.DATA_DIR); - const oneWeekAgo = new Date(); - oneWeekAgo.setDate(oneWeekAgo.getDate() - CONFIG.MAX_POST_AGE_DAYS); + + // Initialize database service if DATABASE_URL is available + let taskService: TaskService | null = null; + try { + taskService = new TaskService(); + await taskService.ensureTablesExist(); + const dbCount = await taskService.countTasks(); + Logger.info(`Database connection OK. Current tasks in DB: ${dbCount}`); + } catch (dbError) { + Logger.warn(`Database not available, saving to files only: ${dbError}`); + taskService = null; + } + + const cutoffDate = sinceDate || new Date(Date.now() - CONFIG.MAX_POST_AGE_DAYS * 24 * 60 * 60 * 1000); try { - Logger.info("Starting Telegram Crawl (Last Week Only)"); - Logger.info(`Looking for posts newer than: ${oneWeekAgo.toLocaleDateString()}`); + Logger.info(`Starting Telegram Crawl (since ${cutoffDate.toISOString().split('T')[0]})`); + Logger.info(`Looking for posts newer than: ${cutoffDate.toLocaleDateString()}`); - const existingIds = await storage.getExistingIds(); - const existingContent = await storage.getExistingContent(); + // Use DB for dedup if available, otherwise fall back to file storage + let existingIds: Set; + let existingContent: Set; + + if (taskService) { + existingIds = await taskService.getExistingPostIds(); + existingContent = await taskService.getExistingContentHashes(); + } else { + existingIds = await storage.getExistingIds(); + existingContent = await storage.getExistingContent(); + } Logger.info( - `Database: ${existingIds.size} posts, ${existingContent.size} content hashes` + `Existing: ${existingIds.size} posts, ${existingContent.size} content hashes` ); let totalSaved = 0; @@ -32,8 +69,8 @@ async function crawl(): Promise { const page = await browser.newPage(); try { - const allPosts = await scraper.scrape(page, url); - + const allPosts = await scraper.scrape(page, url, sinceDate); + if (allPosts.length === 0) { Logger.warn(` No posts found from ${url}`); continue; @@ -44,14 +81,14 @@ async function crawl(): Promise { // Проверяем время публикации if (p.timestamp) { const postDate = new Date(p.timestamp); - if (postDate < oneWeekAgo) { + if (postDate < cutoffDate) { totalFilteredByAge++; return false; } } - + // Проверяем дубликаты - const contentHash = `${p.title}|${p.description}`; + const contentHash = `${p.title}|${p.description}`.toLowerCase().trim(); return !existingIds.has(p.id) && !existingContent.has(contentHash); }); @@ -60,20 +97,45 @@ async function crawl(): Promise { continue; } - const result = await storage.saveNewJobs(newPosts); + // Save to database if available + let dbSavedCount = 0; + if (taskService) { + try { + const tasksToSave = newPosts.map((post) => ({ + id_post: post.id, + title: post.title, + description: post.description, + workType: post.workType, + payment: post.payment, + deadline: post.deadline, + url: post.url, + channelUrl: post.channelUrl || "", + scrapedAt: post.scrapedAt, + timestamp: post.timestamp || post.scrapedAt, + })); + const dbResult = await taskService.createManyTasks(tasksToSave); + dbSavedCount = dbResult.count; + Logger.success(` Saved to DB: ${dbSavedCount}`); + } catch (dbError) { + Logger.error(` Database save failed: ${dbError}`); + } + } - Logger.success(` Saved: ${result.saved.length}`); - Logger.info(` Duplicates: ${result.skipped}`); + // Also save to files + const fileResult = await storage.saveNewJobs(newPosts); + + Logger.success(` Saved to files: ${fileResult.saved.length}`); + Logger.info(` Duplicates: ${fileResult.skipped}`); Logger.info(` Filtered by age: ${totalFilteredByAge}`); - totalSaved += result.saved.length; - totalDuplicates += result.skipped; - allSavedPosts.push(...result.saved); + totalSaved += Math.max(dbSavedCount, fileResult.saved.length); + totalDuplicates += fileResult.skipped; + allSavedPosts.push(...newPosts); // Update existing IDs and content for next channel - result.saved.forEach((p) => { + newPosts.forEach((p) => { existingIds.add(p.id); - existingContent.add(`${p.title}|${p.description}`); + existingContent.add(`${p.title}|${p.description}`.toLowerCase().trim()); }); } finally { await page.close(); @@ -98,8 +160,11 @@ async function crawl(): Promise { } catch (error) { Logger.error("Crawl failed", error); } finally { + if (taskService) { + await taskService.disconnect(); + } await browser.close(); } } -crawl(); \ No newline at end of file +crawl(); diff --git a/src/index.ts b/src/index.ts index cf1c416..c5010f7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -467,6 +467,10 @@ const storage = new DataStorage(CONFIG.DATA_DIR); const taskManager = new TaskManager(CONFIG.DATA_DIR); const taskService = new TaskService(); +// Ensure database tables exist (fallback for when Prisma migrations don't run) +taskService.ensureTablesExist().catch((err) => { + Logger.error("Failed to ensure database tables exist:", err); +}); app.get("/", (c) => { return c.json({ @@ -479,7 +483,7 @@ app.get("/", (c) => { "GET /api/file/:filename": "Get posts from specific file", "GET /api/jobs/:id": "Get post by ID", "GET /api/jobs/filter/type/:type": "Filter by work type", - "POST /api/crawl": "Start crawler", + "POST /api/crawl": "Start crawler (optional body: {date: 'YYYY-MM-DD'} to crawl since a specific date)", "GET /api/stats": "Get statistics", }, "Task Management": { @@ -596,10 +600,12 @@ app.get("/api/jobs/filter/type/:type", async (c) => { }); // Extracted crawl runner so it can be used by API and on-start hooks -async function runCrawlAndSave(): Promise<{ success: boolean; message?: string; posts?: JobPost[]; error?: any }> { +async function runCrawlAndSave(sinceDate?: Date): Promise<{ success: boolean; message?: string; posts?: JobPost[]; error?: any }> { const browser = await launchBrowser(); const scraper = ServiceFactory.createScraper(); const allCollected: JobPost[] = []; + let totalDbSaved = 0; + let totalFileSaved = 0; try { Logger.info(`ℹ️ Starting crawl of ${CONFIG.TELEGRAM_URLS.length} channels`); @@ -622,7 +628,7 @@ async function runCrawlAndSave(): Promise<{ success: boolean; message?: string; const page = await browser.newPage(); try { - const allPosts = await scraper.scrape(page, url); + const allPosts = await scraper.scrape(page, url, sinceDate); const newPosts = allPosts.filter((p) => { const contentHash = `${p.title}|${p.description}`.toLowerCase().trim(); @@ -649,26 +655,26 @@ async function runCrawlAndSave(): Promise<{ success: boolean; message?: string; })); Logger.info(`📝 Attempting to save ${tasksToSave.length} tasks to database...`); - - let savedCount = 0; - let skippedCount = 0; - + + let dbSavedCount = 0; + try { const result = await taskService.createManyTasks(tasksToSave); - savedCount = result.count; - skippedCount = newPosts.length - savedCount; + dbSavedCount = result.count; + totalDbSaved += dbSavedCount; - Logger.success(`✅ Saved to DB: ${savedCount}, Duplicates: ${skippedCount}`); - - if (savedCount === 0 && newPosts.length > 0) { - Logger.warn(`⚠️ Warning: ${newPosts.length} posts were not saved. All might be duplicates.`); - } + Logger.success(`✅ Saved to DB: ${dbSavedCount}, Duplicates: ${newPosts.length - dbSavedCount}`); } catch (dbError) { Logger.error(`❌ Database error while saving tasks:`, dbError); - Logger.error("Failed to save tasks to database", dbError); - // Если ошибка, считаем что ничего не сохранилось - savedCount = 0; - skippedCount = newPosts.length; + } + + // Also save to files as backup + try { + const fileResult = await storage.saveNewJobs(newPosts); + totalFileSaved += fileResult.saved.length; + Logger.success(`✅ Saved to files: ${fileResult.saved.length}`); + } catch (fileError) { + Logger.error(`❌ File save error:`, fileError); } // Обновляем существующие для следующего канала @@ -677,7 +683,8 @@ async function runCrawlAndSave(): Promise<{ success: boolean; message?: string; existingContent.add(`${p.title}|${p.description}`.toLowerCase().trim()); }); - allCollected.push(...newPosts.slice(0, savedCount)); + // Include all crawled posts in response regardless of DB save result + allCollected.push(...newPosts); } finally { await page.close(); } @@ -687,7 +694,7 @@ async function runCrawlAndSave(): Promise<{ success: boolean; message?: string; return { success: true, message: "No new posts found", posts: [] }; } - return { success: true, message: `Collected ${allCollected.length} new posts from ${CONFIG.TELEGRAM_URLS.length} channels`, posts: allCollected }; + return { success: true, message: `Collected ${allCollected.length} new posts (DB: ${totalDbSaved}, files: ${totalFileSaved})`, posts: allCollected }; } catch (error) { Logger.error(`❌ Crawl failed:`, error); return { success: false, error }; @@ -697,7 +704,21 @@ async function runCrawlAndSave(): Promise<{ success: boolean; message?: string; } app.post("/api/crawl", async (c) => { - const result = await runCrawlAndSave(); + let sinceDate: Date | undefined; + try { + const body = await c.req.json().catch(() => ({})); + if (body.date) { + const parsed = new Date(body.date); + if (isNaN(parsed.getTime())) { + return c.json({ success: false, error: "Invalid date format. Use ISO 8601 (e.g. 2025-01-15) or any Date-parseable string." }, 400); + } + sinceDate = parsed; + } + } catch { + // No body or invalid JSON — proceed with defaults + } + + const result = await runCrawlAndSave(sinceDate); if (result.success) { return c.json({ success: true, message: result.message, posts: result.posts }); } else { diff --git a/src/services/database/task.ts b/src/services/database/task.ts index 9c52aaa..8e63d16 100644 --- a/src/services/database/task.ts +++ b/src/services/database/task.ts @@ -18,23 +18,66 @@ export interface TaskData { export class TaskService { private prisma: PrismaClient; + private pool: Pool; constructor() { const connectionString = process.env.DATABASE_URL; - + if (!connectionString) { throw new Error("DATABASE_URL environment variable is not set"); } - const pool = new Pool({ connectionString }); - const adapter = new PrismaPg(pool); - + this.pool = new Pool({ connectionString }); + const adapter = new PrismaPg(this.pool); + this.prisma = new PrismaClient({ adapter, log: process.env.NODE_ENV === 'development' ? ['error', 'warn'] : ['error'], }); } + /** + * Ensures the Task table and its indexes exist in the database. + * This is a fallback for cases where Prisma migrations fail to run + * (e.g., Prisma 7 config loading issues in Docker containers). + */ + async ensureTablesExist(): Promise { + const client = await this.pool.connect(); + try { + const tableCheck = await client.query( + `SELECT to_regclass('public."Task"') AS table_exists` + ); + if (tableCheck.rows[0].table_exists) { + Logger.info("Database table 'Task' already exists"); + return; + } + + Logger.warn("Table 'Task' not found — creating via raw SQL fallback..."); + await client.query(` + CREATE TABLE IF NOT EXISTS "Task" ( + "id" SERIAL NOT NULL, + "id_post" TEXT NOT NULL, + "title" TEXT NOT NULL, + "description" TEXT NOT NULL, + "workType" TEXT NOT NULL, + "payment" TEXT NOT NULL, + "deadline" TEXT NOT NULL, + "url" TEXT NOT NULL, + "channelUrl" TEXT NOT NULL, + "scrapedAt" TEXT NOT NULL, + "timestamp" TEXT NOT NULL, + CONSTRAINT "Task_pkey" PRIMARY KEY ("id") + ); + `); + await client.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "Task_id_post_key" ON "Task"("id_post"); + `); + Logger.success("Table 'Task' created successfully via SQL fallback"); + } finally { + client.release(); + } + } + async createTask(data: TaskData) { try { const task = await this.prisma.task.create({ diff --git a/src/services/extractor.ts b/src/services/extractor.ts index d346bc5..223a432 100644 --- a/src/services/extractor.ts +++ b/src/services/extractor.ts @@ -37,6 +37,8 @@ export class PostExtractor { return null; } + const postTimestamp = this.htmlParser.extractTimestamp($el); + return { id: postId, title, @@ -44,10 +46,10 @@ export class PostExtractor { workType: this.parser.extractField(text, "тип работы|type"), payment: this.parser.extractField(text, "оплата|payment"), deadline: this.parser.extractField(text, "сроки|deadline|срок"), - url: `${baseUrl}?q=${postId}`, + url: `${baseUrl.replace('/s/', '/')}/${postId}`, channelUrl: baseUrl, scrapedAt: new Date().toISOString(), - timestamp: new Date().toISOString(), + timestamp: postTimestamp ? postTimestamp.toISOString() : new Date().toISOString(), }; } catch { return null; diff --git a/src/services/parser/htmlParser.ts b/src/services/parser/htmlParser.ts index 7720366..bcc1b15 100644 --- a/src/services/parser/htmlParser.ts +++ b/src/services/parser/htmlParser.ts @@ -6,14 +6,23 @@ export const SELECTORS = { POST_MESSAGE: ".tgme_widget_message", MESSAGE_TEXT: ".tgme_widget_message_text", POST_LINK: "a[href*='/s/']", - MESSAGE_TIME: "time.datetime", + MESSAGE_TIME: "time[datetime]", MESSAGE_DATE: ".tgme_widget_message_date", }; export class HtmlParser { extractPostId($el: any): string { - const href = $el.find(SELECTORS.POST_LINK).first().attr("href") || ""; - return href.split("/").pop() || `post_${Date.now()}`; + // Prefer data-post attribute (e.g. "digitaltender/5059") + const dataPost = $el.attr("data-post") || ""; + if (dataPost) { + return dataPost.split("/").pop() || dataPost; + } + // Fallback: find the date link which contains the post URL + const dateHref = $el.find(SELECTORS.MESSAGE_DATE + " a").first().attr("href") || ""; + if (dateHref) { + return dateHref.split("/").pop() || `post_${Date.now()}`; + } + return `post_${Date.now()}`; } extractText($el: any): string { diff --git a/src/services/srcaper/scraper.ts b/src/services/srcaper/scraper.ts index bad6c1b..c9c4511 100644 --- a/src/services/srcaper/scraper.ts +++ b/src/services/srcaper/scraper.ts @@ -7,7 +7,7 @@ import { Logger } from "../../log/logger"; export class Scraper { constructor(private extractor: PostExtractor, private htmlParser: HtmlParser) {} - async scrape(page: Page, url: string): Promise { + async scrape(page: Page, url: string, sinceDate?: Date): Promise { Logger.info(`Starting page load: ${url}`); await page.goto(url, { waitUntil: "networkidle" }); @@ -22,8 +22,7 @@ export class Scraper { const allPosts = []; let lastPostId: string | null = null; let foundOldPosts = false; - const oneWeekAgo = new Date(); - oneWeekAgo.setDate(oneWeekAgo.getDate() - 7); + const cutoffDate = sinceDate || new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); for (let i = 0; i < CONFIG.MAX_CRAWL_ITERATIONS && !foundOldPosts; i++) { const posts = await this.extractor.extractFromPage(page, url); @@ -41,11 +40,11 @@ export class Scraper { if (!post.timestamp) return true; const postDate = new Date(post.timestamp); - return postDate >= oneWeekAgo; + return postDate >= cutoffDate; }); if (recentPosts.length === 0) { - Logger.info("Reached posts older than 1 week, stopping"); + Logger.info("Reached posts older than cutoff date, stopping"); foundOldPosts = true; break; } @@ -53,7 +52,7 @@ export class Scraper { allPosts.push(...recentPosts); if (recentPosts.length < posts.length) { - Logger.info("Reached posts older than 1 week, stopping"); + Logger.info("Reached posts older than cutoff date, stopping"); foundOldPosts = true; break; } @@ -69,7 +68,7 @@ export class Scraper { await this.htmlParser.scrollPage(page, 2); } - Logger.success(`Collected ${allPosts.length} posts from the last week`); + Logger.success(`Collected ${allPosts.length} posts since ${cutoffDate.toISOString().split('T')[0]}`); return allPosts; } } \ No newline at end of file diff --git a/src/services/validate/validator.ts b/src/services/validate/validator.ts index fc0d2cc..5741cb2 100644 --- a/src/services/validate/validator.ts +++ b/src/services/validate/validator.ts @@ -2,7 +2,7 @@ const AD_PATTERNS = [ /^\[?реклама\]?/i, /^sponsored/i, /^рассыли/i, - /pinned/i, + /^pinned/i, ]; const SPAM_KEYWORDS = ["спам", "реклама", "pinned", "закреплено"]; @@ -59,12 +59,12 @@ export class ContentValidator { return false; } - // Check for job emoji in both title and description start - const hasJobEmoji = this.hasJobEmoji(title) || + const hasJobEmoji = this.hasJobEmoji(title) || (title + description).substring(0, 200).includes("📌") || (title + description).substring(0, 200).includes("👔") || (title + description).substring(0, 200).includes("💼"); - return this.hasJobKeyword(combined) && hasJobEmoji; + // Accept post if it has job keywords OR job emojis (not requiring both) + return this.hasJobKeyword(combined) || hasJobEmoji; } }