auction-scrapper/app/controllers/auctions_controller.ts
Vakula Uladimir 12f005e335 init
2025-10-17 11:27:52 +03:00

325 lines
10 KiB
TypeScript

import type { HttpContext } from '@adonisjs/core/http'
import Auction from '#models/auction'
import ParseLog from '#models/parse_log'
import { errors as lucidErrors } from '@adonisjs/lucid'
import { DateTime } from 'luxon'
import { ScraperService } from '#services/scraper_service'
import { NotificationService } from '#services/notification_service'
import logger from '@adonisjs/core/services/logger'
export default class AuctionsController {
/**
* List auctions with pagination
* GET /api/auctions?page=1&limit=20
*/
async index({ request, response }: HttpContext) {
const page = request.input('page', 1)
const limit = request.input('limit', 20)
const pageNumber = Math.max(1, Number(page))
const limitNumber = Math.min(100, Math.max(1, Number(limit)))
try {
const auctions = await Auction.query()
.orderBy('created_at', 'desc')
.paginate(pageNumber, limitNumber)
return response.ok(auctions.serialize())
} catch (error) {
return response.internalServerError({
error: 'Failed to fetch auctions',
message: error instanceof Error ? error.message : 'Unknown error',
})
}
}
/**
* Get a single auction by id with relationships
* GET /api/auctions/:id
*/
async show({ params, response }: HttpContext) {
const auctionId = params.id
if (!auctionId || isNaN(Number(auctionId))) {
return response.badRequest({
error: 'Invalid auction id',
})
}
try {
const auction = await Auction.query()
.where('id', auctionId)
.preload('notifications')
.firstOrFail()
return response.ok(auction.serialize())
} catch (error) {
if (error instanceof lucidErrors.E_ROW_NOT_FOUND) {
return response.notFound({
error: 'Auction not found',
})
}
return response.internalServerError({
error: 'Failed to fetch auction',
message: error instanceof Error ? error.message : 'Unknown error',
})
}
}
/**
* Search auctions by keyword/title/status
* GET /api/auctions/search?q=keyword&status=active&page=1&limit=20
*/
async search({ request, response }: HttpContext) {
const searchQuery = request.input('q', '')
const status = request.input('status')
const page = request.input('page', 1)
const limit = request.input('limit', 20)
const pageNumber = Math.max(1, Number(page))
const limitNumber = Math.min(100, Math.max(1, Number(limit)))
try {
const query = Auction.query()
if (searchQuery && typeof searchQuery === 'string' && searchQuery.trim().length > 0) {
const trimmedQuery = searchQuery.trim()
query.where((builder) => {
builder
.whereILike('title', `%${trimmedQuery}%`)
.orWhereILike('description', `%${trimmedQuery}%`)
.orWhereILike('auction_num', `%${trimmedQuery}%`)
.orWhereILike('organization', `%${trimmedQuery}%`)
})
}
if (status && typeof status === 'string' && status.trim().length > 0) {
query.where('status', status.trim())
}
const auctions = await query.orderBy('created_at', 'desc').paginate(pageNumber, limitNumber)
return response.ok(auctions.serialize())
} catch (error) {
return response.internalServerError({
error: 'Failed to search auctions',
message: error instanceof Error ? error.message : 'Unknown error',
})
}
}
/**
* List all auctions from last 3 days
* GET /list
*/
async list({ response }: HttpContext) {
try {
const threeDaysAgo = DateTime.now().minus({ days: 3 })
const auctions = await Auction.query()
.where('created_at', '>=', threeDaysAgo.toSQL())
.orderBy('created_at', 'desc')
return response.ok({
data: auctions.map((auction) => auction.serialize()),
meta: {
total: auctions.length,
from_date: threeDaysAgo.toISO(),
},
})
} catch (error) {
return response.internalServerError({
error: 'Failed to fetch recent auctions',
message: error instanceof Error ? error.message : 'Unknown error',
})
}
}
/**
* Render HTML view of auctions from last 3 days
* GET /list-view
*/
async listView({ view }: HttpContext) {
const threeDaysAgo = DateTime.now().minus({ days: 3 })
const auctions = await Auction.query()
.where('created_at', '>=', threeDaysAgo.toSQL())
.orderBy('created_at', 'desc')
return view.render('auctions/list', {
auctions,
fromDate: threeDaysAgo.toFormat('dd.MM.yyyy HH:mm'),
})
}
/**
* Trigger auction parsing from web interface
* POST /trigger-parse
*/
async triggerParse({ request, response, session }: HttpContext) {
try {
// Validate input
const pages = request.input('pages', 1)
const notifySubscribers = request.input('notifySubscribers') === 'on'
const pagesNumber = Math.max(1, Math.min(10, Number(pages)))
if (isNaN(pagesNumber)) {
session.flash('error', 'Invalid number of pages')
return response.redirect('/list-view')
}
logger.info(`Parsing triggered from web interface`, {
pages: pagesNumber,
notifySubscribers,
})
const startTime = DateTime.now()
const scraper = new ScraperService()
const notificationService = new NotificationService()
// Create ParseLog entry
const parseLog = await ParseLog.create({
parseType: 'auction',
status: 'running',
itemsFound: 0,
errors: null,
startedAt: startTime,
completedAt: null,
})
let newAuctions = 0
let updatedAuctions = 0
let totalScraped = 0
const errors: string[] = []
// Step 1: Scrape auctions
logger.info(`Scraping ${pagesNumber} page(s) from icetrade.by`)
const auctionData = await scraper.scrapeAuctions(pagesNumber)
totalScraped = auctionData.length
if (totalScraped === 0) {
await this.updateParseLog(parseLog, 'completed', 0, null)
session.flash('error', 'No auctions found during parsing')
return response.redirect('/list-view')
}
// Step 2: Upsert auctions to database
logger.info(`Saving ${totalScraped} auction(s) to database`)
for (const data of auctionData) {
try {
// Check if auction exists by auctionNum
const existingAuction = await Auction.query()
.where('auctionNum', data.auctionNum)
.first()
let auction: Auction
if (existingAuction) {
// Update existing auction
existingAuction.title = data.title
existingAuction.description = data.description
existingAuction.organization = data.organization
existingAuction.status = data.status
existingAuction.deadline = data.deadline ? DateTime.fromISO(data.deadline) : null
existingAuction.url = data.link
existingAuction.rawData = data
await existingAuction.save()
auction = existingAuction
updatedAuctions++
logger.debug(`Updated auction: ${data.auctionNum}`)
} else {
// Create new auction
auction = await Auction.create({
auctionNum: data.auctionNum,
title: data.title,
description: data.description,
organization: data.organization,
status: data.status,
price: null,
deadline: data.deadline ? DateTime.fromISO(data.deadline) : null,
url: data.link,
rawData: data,
})
newAuctions++
logger.debug(`Created auction: ${data.auctionNum}`)
// Step 3: Check for keyword matches and send notifications (for new auctions only)
if (notifySubscribers) {
try {
await notificationService.checkAndNotify(auction)
} catch (error) {
const errorMsg = `Notification check failed for auction ${data.auctionNum}: ${error instanceof Error ? error.message : String(error)}`
logger.error(errorMsg)
errors.push(errorMsg)
// Don't fail the entire process if notification fails
}
}
}
} catch (error) {
const errorMsg = `Failed to save auction ${data.auctionNum}: ${error instanceof Error ? error.message : String(error)}`
logger.error(errorMsg)
errors.push(errorMsg)
}
}
// Update ParseLog with success status
const status = errors.length > 0 ? 'completed_with_errors' : 'completed'
await this.updateParseLog(
parseLog,
status,
newAuctions + updatedAuctions,
errors.length > 0 ? errors.join('\n') : null
)
// Create success message
let successMessage = `Parsing completed: ${totalScraped} auctions scraped, ${newAuctions} new, ${updatedAuctions} updated`
if (!notifySubscribers) {
successMessage += ' (notifications disabled)'
}
if (errors.length > 0) {
successMessage += ` with ${errors.length} error(s)`
}
session.flash('success', successMessage)
logger.info(successMessage)
return response.redirect('/list-view')
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error during parsing'
logger.error('Error in triggerParse', {
error: errorMessage,
})
session.flash('error', `Parsing failed: ${errorMessage}`)
return response.redirect('/list-view')
}
}
/**
* Update ParseLog entry with final status
*/
private async updateParseLog(
parseLog: ParseLog,
status: string,
itemsFound: number,
errors: string | null
): Promise<void> {
parseLog.status = status
parseLog.itemsFound = itemsFound
parseLog.errors = errors
parseLog.completedAt = DateTime.now()
await parseLog.save()
logger.info(`Updated ParseLog ${parseLog.id}: status=${status}, items=${itemsFound}`)
}
}