First version of new crawler
This commit is contained in:
52
src/app.module.ts
Normal file
52
src/app.module.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { MongooseModule } from '@nestjs/mongoose';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { CrawlerService } from './crawler/crawler.service';
|
||||
import { PricesController } from './controllers/prices.controller';
|
||||
import { SearchController } from './controllers/search.controller';
|
||||
import { StatsController } from './controllers/stats.controller';
|
||||
import { HealthController } from './controllers/health.controller';
|
||||
import { YadakmarketController } from './controllers/yadakmarket.controller';
|
||||
import { CrawlController } from './controllers/crawl.controller';
|
||||
import { AkharinCar, AkharinCarSchema } from './schemas/akharin-car.schema';
|
||||
import { HamrahCar, HamrahCarSchema } from './schemas/hamrah-car.schema';
|
||||
import { YadakmarketPart, YadakmarketPartSchema } from './schemas/yadakmarket-part.schema';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
envFilePath: '.env',
|
||||
expandVariables: true,
|
||||
}),
|
||||
ScheduleModule.forRoot(),
|
||||
MongooseModule.forRootAsync({
|
||||
useFactory: (config: ConfigService) => {
|
||||
const uri =
|
||||
config.get<string>('CAR_CRAWLER_DB_CONNECTION_STRING') ||
|
||||
process.env.CAR_CRAWLER_DB_CONNECTION_STRING;
|
||||
if (!uri || uri.includes('${')) {
|
||||
throw new Error('CAR_CRAWLER_DB_CONNECTION_STRING is required and must be a valid MongoDB URI.');
|
||||
}
|
||||
return { uri };
|
||||
},
|
||||
inject: [ConfigService],
|
||||
}),
|
||||
MongooseModule.forFeature([
|
||||
{ name: AkharinCar.name, schema: AkharinCarSchema },
|
||||
{ name: HamrahCar.name, schema: HamrahCarSchema },
|
||||
{ name: YadakmarketPart.name, schema: YadakmarketPartSchema },
|
||||
]),
|
||||
],
|
||||
controllers: [
|
||||
PricesController,
|
||||
SearchController,
|
||||
StatsController,
|
||||
HealthController,
|
||||
YadakmarketController,
|
||||
CrawlController,
|
||||
],
|
||||
providers: [CrawlerService],
|
||||
})
|
||||
export class AppModule {}
|
||||
16
src/cli/crawl.ts
Normal file
16
src/cli/crawl.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { AppModule } from '../app.module';
|
||||
import { CrawlerService } from '../crawler/crawler.service';
|
||||
|
||||
async function run() {
|
||||
const app = await NestFactory.createApplicationContext(AppModule);
|
||||
const crawler = app.get(CrawlerService);
|
||||
await crawler.runAll();
|
||||
await app.close();
|
||||
}
|
||||
|
||||
run().catch((err) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
16
src/controllers/crawl.controller.ts
Normal file
16
src/controllers/crawl.controller.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Controller, Post } from '@nestjs/common';
|
||||
import { ApiTags, ApiResponse } from '@nestjs/swagger';
|
||||
import { CrawlerService } from '../crawler/crawler.service';
|
||||
|
||||
@ApiTags('crawler')
|
||||
@Controller()
|
||||
export class CrawlController {
|
||||
constructor(private readonly crawler: CrawlerService) {}
|
||||
|
||||
@Post('crawl')
|
||||
@ApiResponse({ status: 200, description: 'Trigger a crawl immediately.' })
|
||||
async runCrawl() {
|
||||
await this.crawler.runAll();
|
||||
return { ok: true, message: 'Crawl completed' };
|
||||
}
|
||||
}
|
||||
15
src/controllers/health.controller.ts
Normal file
15
src/controllers/health.controller.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { ApiTags, ApiResponse } from '@nestjs/swagger';
|
||||
|
||||
@ApiTags('health')
|
||||
@Controller()
|
||||
export class HealthController {
|
||||
@Get('health')
|
||||
@ApiResponse({ status: 200, description: 'Health check.' })
|
||||
health() {
|
||||
return {
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
68
src/controllers/prices.controller.ts
Normal file
68
src/controllers/prices.controller.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { Controller, Get, Query, BadRequestException } from '@nestjs/common';
|
||||
import { ApiQuery, ApiTags, ApiResponse } from '@nestjs/swagger';
|
||||
import { InjectModel } from '@nestjs/mongoose';
|
||||
import { Model } from 'mongoose';
|
||||
import { AkharinCar, AkharinCarDocument } from '../schemas/akharin-car.schema';
|
||||
import { HamrahCar, HamrahCarDocument } from '../schemas/hamrah-car.schema';
|
||||
import { YadakmarketPart, YadakmarketPartDocument } from '../schemas/yadakmarket-part.schema';
|
||||
|
||||
@ApiTags('prices')
|
||||
@Controller()
|
||||
export class PricesController {
|
||||
constructor(
|
||||
@InjectModel(AkharinCar.name) private readonly akharinModel: Model<AkharinCarDocument>,
|
||||
@InjectModel(HamrahCar.name) private readonly hamrahModel: Model<HamrahCarDocument>,
|
||||
@InjectModel(YadakmarketPart.name) private readonly yadakModel: Model<YadakmarketPartDocument>,
|
||||
) {}
|
||||
|
||||
@Get('price')
|
||||
@ApiQuery({ name: 'source', enum: ['akharin', 'hamrah', 'yadakmarket'], required: true })
|
||||
@ApiResponse({ status: 200, description: 'List of prices by source.' })
|
||||
async getPrices(@Query('source') source?: string) {
|
||||
if (!source) {
|
||||
throw new BadRequestException({
|
||||
ok: false,
|
||||
message: 'use query params',
|
||||
enums: ['akharin', 'hamrah', 'yadakmarket'],
|
||||
example: '/price?source=akharin',
|
||||
});
|
||||
}
|
||||
|
||||
switch (source) {
|
||||
case 'akharin': {
|
||||
const data = await this.akharinModel.find({}).sort({ crawledAt: -1 }).limit(1000).lean();
|
||||
return data.map((doc) => ({
|
||||
carName: doc.carName,
|
||||
marketPrice: doc.marketPrice,
|
||||
newCarPrice: doc.newCarPrice,
|
||||
}));
|
||||
}
|
||||
case 'hamrah': {
|
||||
const data = await this.hamrahModel.find({}).sort({ crawledAt: -1 }).limit(1000).lean();
|
||||
return data.map((doc) => ({
|
||||
carName: doc.carName,
|
||||
typeAndYear: doc.typeAndYear,
|
||||
marketPrice: doc.marketPrice,
|
||||
}));
|
||||
}
|
||||
case 'yadakmarket': {
|
||||
const data = await this.yadakModel.find({}).sort({ crawledAt: -1 }).limit(1000).lean();
|
||||
return data
|
||||
.filter((doc) => doc && doc.productName)
|
||||
.map((doc) => ({
|
||||
productName: doc.productName,
|
||||
productUrl: doc.productUrl || null,
|
||||
productId: doc.productId || null,
|
||||
currentPrice: doc.currentPrice ?? null,
|
||||
maxPrice: doc.maxPrice ?? null,
|
||||
}));
|
||||
}
|
||||
default:
|
||||
throw new BadRequestException({
|
||||
ok: false,
|
||||
message: 'Invalid source',
|
||||
enums: ['akharin', 'hamrah', 'yadakmarket'],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
82
src/controllers/search.controller.ts
Normal file
82
src/controllers/search.controller.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { Controller, Get, Query, BadRequestException } from '@nestjs/common';
|
||||
import { ApiQuery, ApiTags, ApiResponse } from '@nestjs/swagger';
|
||||
import { InjectModel } from '@nestjs/mongoose';
|
||||
import { Model } from 'mongoose';
|
||||
import { AkharinCar, AkharinCarDocument } from '../schemas/akharin-car.schema';
|
||||
import { HamrahCar, HamrahCarDocument } from '../schemas/hamrah-car.schema';
|
||||
import { YadakmarketPart, YadakmarketPartDocument } from '../schemas/yadakmarket-part.schema';
|
||||
|
||||
@ApiTags('search')
|
||||
@Controller()
|
||||
export class SearchController {
|
||||
constructor(
|
||||
@InjectModel(AkharinCar.name) private readonly akharinModel: Model<AkharinCarDocument>,
|
||||
@InjectModel(HamrahCar.name) private readonly hamrahModel: Model<HamrahCarDocument>,
|
||||
@InjectModel(YadakmarketPart.name) private readonly yadakModel: Model<YadakmarketPartDocument>,
|
||||
) {}
|
||||
|
||||
@Get('search')
|
||||
@ApiQuery({ name: 'q', required: true, type: String })
|
||||
@ApiQuery({ name: 'source', required: false, enum: ['akharin', 'hamrah', 'yadakmarket'] })
|
||||
@ApiResponse({ status: 200, description: 'Search results across sources.' })
|
||||
async search(@Query('q') q?: string, @Query('source') source?: string) {
|
||||
if (!q) {
|
||||
throw new BadRequestException({
|
||||
ok: false,
|
||||
message: 'Query parameter "q" is required',
|
||||
example: '/search?q=پژو&source=akharin',
|
||||
});
|
||||
}
|
||||
|
||||
const searchRegex = new RegExp(q, 'i');
|
||||
const results: any[] = [];
|
||||
|
||||
if (source === 'akharin' || !source) {
|
||||
const data = await this.akharinModel.find({ carName: searchRegex }).limit(100).lean();
|
||||
results.push(
|
||||
...data.map((doc) => ({
|
||||
source: 'akharin',
|
||||
carName: doc.carName,
|
||||
marketPrice: doc.marketPrice,
|
||||
newCarPrice: doc.newCarPrice,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
if (source === 'hamrah' || !source) {
|
||||
const data = await this.hamrahModel.find({ carName: searchRegex }).limit(100).lean();
|
||||
results.push(
|
||||
...data.map((doc) => ({
|
||||
source: 'hamrah',
|
||||
carName: doc.carName,
|
||||
typeAndYear: doc.typeAndYear,
|
||||
marketPrice: doc.marketPrice,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
if (source === 'yadakmarket' || !source) {
|
||||
const data = await this.yadakModel.find({ productName: searchRegex }).limit(100).lean();
|
||||
results.push(
|
||||
...data
|
||||
.filter((doc) => doc && doc.productName)
|
||||
.map((doc) => ({
|
||||
source: 'yadakmarket',
|
||||
productName: doc.productName,
|
||||
productUrl: doc.productUrl || null,
|
||||
productId: doc.productId || null,
|
||||
currentPrice: doc.currentPrice ?? null,
|
||||
maxPrice: doc.maxPrice ?? null,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
query: q,
|
||||
source: source || 'all',
|
||||
count: results.length,
|
||||
results,
|
||||
};
|
||||
}
|
||||
}
|
||||
51
src/controllers/stats.controller.ts
Normal file
51
src/controllers/stats.controller.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { ApiTags, ApiResponse } from '@nestjs/swagger';
|
||||
import { InjectModel } from '@nestjs/mongoose';
|
||||
import { Model } from 'mongoose';
|
||||
import { AkharinCar, AkharinCarDocument } from '../schemas/akharin-car.schema';
|
||||
import { HamrahCar, HamrahCarDocument } from '../schemas/hamrah-car.schema';
|
||||
import { YadakmarketPart, YadakmarketPartDocument } from '../schemas/yadakmarket-part.schema';
|
||||
|
||||
@ApiTags('prices')
|
||||
@Controller()
|
||||
export class StatsController {
|
||||
constructor(
|
||||
@InjectModel(AkharinCar.name) private readonly akharinModel: Model<AkharinCarDocument>,
|
||||
@InjectModel(HamrahCar.name) private readonly hamrahModel: Model<HamrahCarDocument>,
|
||||
@InjectModel(YadakmarketPart.name) private readonly yadakModel: Model<YadakmarketPartDocument>,
|
||||
) {}
|
||||
|
||||
@Get('stats')
|
||||
@ApiResponse({ status: 200, description: 'Stats for each source.' })
|
||||
async getStats() {
|
||||
const [akharinCount, akharinLast] = await Promise.all([
|
||||
this.akharinModel.countDocuments(),
|
||||
this.akharinModel.findOne().sort({ crawledAt: -1 }).select('crawledAt').lean(),
|
||||
]);
|
||||
|
||||
const [hamrahCount, hamrahLast] = await Promise.all([
|
||||
this.hamrahModel.countDocuments(),
|
||||
this.hamrahModel.findOne().sort({ crawledAt: -1 }).select('crawledAt').lean(),
|
||||
]);
|
||||
|
||||
const [yadakCount, yadakLast] = await Promise.all([
|
||||
this.yadakModel.countDocuments(),
|
||||
this.yadakModel.findOne().sort({ crawledAt: -1 }).select('crawledAt').lean(),
|
||||
]);
|
||||
|
||||
return {
|
||||
akharin: {
|
||||
total: akharinCount,
|
||||
lastCrawled: akharinLast?.crawledAt?.toISOString() || null,
|
||||
},
|
||||
hamrah: {
|
||||
total: hamrahCount,
|
||||
lastCrawled: hamrahLast?.crawledAt?.toISOString() || null,
|
||||
},
|
||||
yadakmarket: {
|
||||
total: yadakCount,
|
||||
lastCrawled: yadakLast?.crawledAt?.toISOString() || null,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
31
src/controllers/yadakmarket.controller.ts
Normal file
31
src/controllers/yadakmarket.controller.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Controller, Get, Param, NotFoundException } from '@nestjs/common';
|
||||
import { ApiTags, ApiResponse, ApiParam } from '@nestjs/swagger';
|
||||
import { InjectModel } from '@nestjs/mongoose';
|
||||
import { Model } from 'mongoose';
|
||||
import { YadakmarketPart, YadakmarketPartDocument } from '../schemas/yadakmarket-part.schema';
|
||||
|
||||
@ApiTags('prices')
|
||||
@Controller()
|
||||
export class YadakmarketController {
|
||||
constructor(
|
||||
@InjectModel(YadakmarketPart.name) private readonly yadakModel: Model<YadakmarketPartDocument>,
|
||||
) {}
|
||||
|
||||
@Get('yadakmarket/:productId')
|
||||
@ApiParam({ name: 'productId', type: String })
|
||||
@ApiResponse({ status: 200, description: 'Single yadakmarket product.' })
|
||||
async getById(@Param('productId') productId: string) {
|
||||
const product = await this.yadakModel.findOne({ productId }).lean();
|
||||
if (!product) {
|
||||
throw new NotFoundException({ ok: false, message: 'Product not found' });
|
||||
}
|
||||
|
||||
return {
|
||||
productName: product.productName,
|
||||
productUrl: product.productUrl || null,
|
||||
productId: product.productId || null,
|
||||
currentPrice: product.currentPrice ?? null,
|
||||
maxPrice: product.maxPrice ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
284
src/crawler/crawler.service.ts
Normal file
284
src/crawler/crawler.service.ts
Normal file
@@ -0,0 +1,284 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { InjectModel } from '@nestjs/mongoose';
|
||||
import { Model } from 'mongoose';
|
||||
import axios from 'axios';
|
||||
import * as cheerio from 'cheerio';
|
||||
import { AkharinCar, AkharinCarDocument } from '../schemas/akharin-car.schema';
|
||||
import { HamrahCar, HamrahCarDocument } from '../schemas/hamrah-car.schema';
|
||||
import { YadakmarketPart, YadakmarketPartDocument } from '../schemas/yadakmarket-part.schema';
|
||||
|
||||
@Injectable()
|
||||
export class CrawlerService {
|
||||
private readonly logger = new Logger(CrawlerService.name);
|
||||
private readonly akharinUrl = 'https://akharinkhodro.ir/price/%D9%82%DB%8C%D9%85%D8%AA-%D8%AE%D9%88%D8%AF%D8%B1%D9%88/';
|
||||
private readonly hamrahUrl = 'https://www.hamrah-mechanic.com/carprice/';
|
||||
|
||||
constructor(
|
||||
@InjectModel(AkharinCar.name) private readonly akharinModel: Model<AkharinCarDocument>,
|
||||
@InjectModel(HamrahCar.name) private readonly hamrahModel: Model<HamrahCarDocument>,
|
||||
@InjectModel(YadakmarketPart.name) private readonly yadakModel: Model<YadakmarketPartDocument>,
|
||||
) {}
|
||||
|
||||
@Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)
|
||||
async cronAkharin(): Promise<void> {
|
||||
await this.crawlAkharin();
|
||||
}
|
||||
|
||||
@Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)
|
||||
async cronHamrah(): Promise<void> {
|
||||
await this.crawlHamrah();
|
||||
}
|
||||
|
||||
@Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)
|
||||
async cronYadakmarket(): Promise<void> {
|
||||
await this.crawlYadakmarket();
|
||||
}
|
||||
|
||||
async runAll(): Promise<void> {
|
||||
await this.crawlAkharin();
|
||||
await this.crawlHamrah();
|
||||
await this.crawlYadakmarket();
|
||||
}
|
||||
|
||||
private parseYadakPrice(
|
||||
priceHtml: string | null,
|
||||
): { currentPrice: string | null; maxPrice: string | null } {
|
||||
if (!priceHtml) return { currentPrice: null, maxPrice: null };
|
||||
|
||||
const $ = cheerio.load(priceHtml);
|
||||
if ($('a[href^="tel:"]').length > 0) return { currentPrice: null, maxPrice: null };
|
||||
|
||||
const priceAmounts: string[] = [];
|
||||
$('.woocommerce-Price-amount').each((_, el) => {
|
||||
const priceText = $(el).find('bdi').text().trim();
|
||||
const cleaned = priceText.replace(/[^\d]/g, '');
|
||||
if (cleaned) priceAmounts.push(cleaned);
|
||||
});
|
||||
|
||||
if (priceAmounts.length === 0) return { currentPrice: null, maxPrice: null };
|
||||
if (priceAmounts.length === 1) return { currentPrice: priceAmounts[0], maxPrice: null };
|
||||
|
||||
const prices = priceAmounts.map((p) => parseInt(p, 10)).sort((a, b) => a - b);
|
||||
return { currentPrice: prices[0].toString(), maxPrice: prices[prices.length - 1].toString() };
|
||||
}
|
||||
|
||||
async crawlAkharin(): Promise<number> {
|
||||
this.logger.log('Crawling Akharin...');
|
||||
const result: Partial<AkharinCar>[] = [];
|
||||
|
||||
try {
|
||||
const { data } = await axios.get(this.akharinUrl, { headers: { 'User-Agent': 'Mozilla/5.0' } });
|
||||
const $ = cheerio.load(data);
|
||||
|
||||
$('tr').each((_, tr) => {
|
||||
const tds = $(tr).find('td');
|
||||
if (tds.length < 3) return;
|
||||
|
||||
const carName = $(tds[0]).text().trim();
|
||||
if (!carName || carName === 'نام خودرو') return;
|
||||
|
||||
const marketText = $(tds[1]).text().trim();
|
||||
const newCarText = $(tds[2]).text().trim();
|
||||
|
||||
const marketPrice =
|
||||
marketText.split(' ')[0] === 'بدون' ? null : marketText.split(' ')[0] || null;
|
||||
const newCarPrice = newCarText.split(' ')[0] || null;
|
||||
|
||||
result.push({
|
||||
carName,
|
||||
marketPrice,
|
||||
newCarPrice,
|
||||
crawledAt: new Date(),
|
||||
});
|
||||
});
|
||||
|
||||
const now = new Date();
|
||||
for (const item of result) {
|
||||
const existing = await this.akharinModel
|
||||
.findOne({ carName: item.carName })
|
||||
.select('marketPrice newCarPrice priceChangedAt')
|
||||
.lean();
|
||||
const priceChanged =
|
||||
!existing ||
|
||||
existing.marketPrice !== item.marketPrice ||
|
||||
existing.newCarPrice !== item.newCarPrice;
|
||||
|
||||
await this.akharinModel.updateOne(
|
||||
{ carName: item.carName },
|
||||
{
|
||||
$set: {
|
||||
carName: item.carName,
|
||||
marketPrice: item.marketPrice ?? null,
|
||||
newCarPrice: item.newCarPrice ?? null,
|
||||
crawledAt: now,
|
||||
updatedAt: now,
|
||||
priceChangedAt: priceChanged ? now : existing?.priceChangedAt ?? null,
|
||||
},
|
||||
$setOnInsert: { createdAt: now },
|
||||
},
|
||||
{ upsert: true },
|
||||
);
|
||||
}
|
||||
|
||||
this.logger.log(`Akharin: saved ${result.length} items.`);
|
||||
return result.length;
|
||||
} catch (err) {
|
||||
this.logger.error('Akharin crawl failed', err);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
async crawlHamrah(): Promise<number> {
|
||||
this.logger.log('Crawling Hamrah...');
|
||||
const result: Partial<HamrahCar>[] = [];
|
||||
|
||||
try {
|
||||
const { data } = await axios.get(this.hamrahUrl, { headers: { 'User-Agent': 'Mozilla/5.0' } });
|
||||
const $ = cheerio.load(data);
|
||||
|
||||
$('tbody tr').each((_, tr) => {
|
||||
const tds = $(tr).find('td');
|
||||
const modelDivs = $(tds[0]).find('div');
|
||||
const priceDivs = $(tds[1]).find('div');
|
||||
|
||||
const carName = $(modelDivs[0]).text().trim();
|
||||
const typeAndYear = $(modelDivs[1]).text().replace(/\s+/g, ' ').trim();
|
||||
const marketPrice = $(priceDivs[0]).text().trim();
|
||||
|
||||
result.push({ carName, typeAndYear, marketPrice, crawledAt: new Date() });
|
||||
});
|
||||
|
||||
const now = new Date();
|
||||
for (const item of result) {
|
||||
const existing = await this.hamrahModel
|
||||
.findOne({ carName: item.carName, typeAndYear: item.typeAndYear })
|
||||
.select('marketPrice priceChangedAt')
|
||||
.lean();
|
||||
const priceChanged = !existing || existing.marketPrice !== item.marketPrice;
|
||||
|
||||
await this.hamrahModel.updateOne(
|
||||
{ carName: item.carName, typeAndYear: item.typeAndYear },
|
||||
{
|
||||
$set: {
|
||||
carName: item.carName,
|
||||
typeAndYear: item.typeAndYear ?? null,
|
||||
marketPrice: item.marketPrice ?? null,
|
||||
crawledAt: now,
|
||||
updatedAt: now,
|
||||
priceChangedAt: priceChanged ? now : existing?.priceChangedAt ?? null,
|
||||
},
|
||||
$setOnInsert: { createdAt: now },
|
||||
},
|
||||
{ upsert: true },
|
||||
);
|
||||
}
|
||||
|
||||
this.logger.log(`Hamrah: saved ${result.length} items.`);
|
||||
return result.length;
|
||||
} catch (err) {
|
||||
this.logger.error('Hamrah crawl failed', err);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
async crawlYadakmarket(): Promise<number> {
|
||||
this.logger.log('Crawling Yadakmarket...');
|
||||
const result: Partial<YadakmarketPart>[] = [];
|
||||
let page = 1;
|
||||
let hasMorePages = true;
|
||||
|
||||
try {
|
||||
while (hasMorePages) {
|
||||
const pageUrl = page === 1
|
||||
? 'https://www.yadakmarket.com/لوازم-بدنه/page/1/?loop=60&woo_ajax=1'
|
||||
: `https://www.yadakmarket.com/لوازم-بدنه/page/${page}/?loop=60&woo_ajax=1`;
|
||||
|
||||
if (page === 1 || page % 10 === 0) {
|
||||
this.logger.log(`Yadakmarket progress: page ${page}`);
|
||||
}
|
||||
const { data } = await axios.get(pageUrl, {
|
||||
headers: { 'User-Agent': 'Mozilla/5.0', 'Accept': 'application/json' },
|
||||
timeout: 15000,
|
||||
});
|
||||
|
||||
if (!data?.items || data.status !== 'have-posts') {
|
||||
hasMorePages = false;
|
||||
break;
|
||||
}
|
||||
|
||||
const $ = cheerio.load(data.items);
|
||||
const products = $('div.wd-product');
|
||||
if (products.length === 0) {
|
||||
hasMorePages = false;
|
||||
break;
|
||||
}
|
||||
|
||||
products.each((_, product) => {
|
||||
const $product = $(product);
|
||||
const titleLink = $product.find('h3.wd-entities-title a');
|
||||
const productName = titleLink.text().trim();
|
||||
const productUrl = titleLink.attr('href') || null;
|
||||
const productId = $product.attr('data-id') || null;
|
||||
const priceElement = $product.find('span.price').html() || null;
|
||||
const price = this.parseYadakPrice(priceElement);
|
||||
|
||||
if (productName) {
|
||||
result.push({
|
||||
productName,
|
||||
productUrl,
|
||||
productId,
|
||||
currentPrice: price.currentPrice,
|
||||
maxPrice: price.maxPrice,
|
||||
crawledAt: new Date(),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (data.nextPage) {
|
||||
page++;
|
||||
await new Promise((r) => setTimeout(r, 1200));
|
||||
} else {
|
||||
hasMorePages = false;
|
||||
}
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
for (const item of result) {
|
||||
const filter = item.productId ? { productId: item.productId } : { productName: item.productName };
|
||||
const existing = await this.yadakModel
|
||||
.findOne(filter)
|
||||
.select('currentPrice maxPrice priceChangedAt')
|
||||
.lean();
|
||||
const priceChanged =
|
||||
!existing ||
|
||||
existing.currentPrice !== item.currentPrice ||
|
||||
existing.maxPrice !== item.maxPrice;
|
||||
|
||||
await this.yadakModel.updateOne(
|
||||
filter,
|
||||
{
|
||||
$set: {
|
||||
productName: item.productName,
|
||||
productUrl: item.productUrl ?? null,
|
||||
productId: item.productId ?? null,
|
||||
currentPrice: item.currentPrice ?? null,
|
||||
maxPrice: item.maxPrice ?? null,
|
||||
crawledAt: now,
|
||||
updatedAt: now,
|
||||
priceChangedAt: priceChanged ? now : existing?.priceChangedAt ?? null,
|
||||
},
|
||||
$setOnInsert: { createdAt: now },
|
||||
},
|
||||
{ upsert: true },
|
||||
);
|
||||
}
|
||||
|
||||
this.logger.log(`Yadakmarket: saved ${result.length} items.`);
|
||||
return result.length;
|
||||
} catch (err) {
|
||||
this.logger.error('Yadakmarket crawl failed', err);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
27
src/main.ts
Normal file
27
src/main.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import 'reflect-metadata';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule, { cors: true });
|
||||
app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true }));
|
||||
|
||||
const config = app.get(ConfigService);
|
||||
const port = config.get<number>('CAR_CRAWLER_APP_PORT', 3000);
|
||||
|
||||
const swaggerConfig = new DocumentBuilder()
|
||||
.setTitle('Price Crawler API')
|
||||
.setDescription('API for accessing crawled car prices and parts data')
|
||||
.setVersion('1.0.0')
|
||||
.build();
|
||||
|
||||
const document = SwaggerModule.createDocument(app, swaggerConfig);
|
||||
SwaggerModule.setup('docs', app, document);
|
||||
|
||||
await app.listen(port);
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
24
src/schemas/akharin-car.schema.ts
Normal file
24
src/schemas/akharin-car.schema.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
|
||||
import { HydratedDocument } from 'mongoose';
|
||||
|
||||
export type AkharinCarDocument = HydratedDocument<AkharinCar>;
|
||||
|
||||
@Schema({ timestamps: true })
|
||||
export class AkharinCar {
|
||||
@Prop({ required: true, index: true })
|
||||
carName!: string;
|
||||
|
||||
@Prop({ type: String, default: null })
|
||||
marketPrice!: string | null;
|
||||
|
||||
@Prop({ type: String, default: null })
|
||||
newCarPrice!: string | null;
|
||||
|
||||
@Prop({ type: Date, default: null })
|
||||
priceChangedAt!: Date | null;
|
||||
|
||||
@Prop({ default: Date.now, index: true })
|
||||
crawledAt!: Date;
|
||||
}
|
||||
|
||||
export const AkharinCarSchema = SchemaFactory.createForClass(AkharinCar);
|
||||
24
src/schemas/hamrah-car.schema.ts
Normal file
24
src/schemas/hamrah-car.schema.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
|
||||
import { HydratedDocument } from 'mongoose';
|
||||
|
||||
export type HamrahCarDocument = HydratedDocument<HamrahCar>;
|
||||
|
||||
@Schema({ timestamps: true })
|
||||
export class HamrahCar {
|
||||
@Prop({ required: true, index: true })
|
||||
carName!: string;
|
||||
|
||||
@Prop({ type: String, default: null })
|
||||
typeAndYear!: string | null;
|
||||
|
||||
@Prop({ type: String, default: null })
|
||||
marketPrice!: string | null;
|
||||
|
||||
@Prop({ type: Date, default: null })
|
||||
priceChangedAt!: Date | null;
|
||||
|
||||
@Prop({ default: Date.now, index: true })
|
||||
crawledAt!: Date;
|
||||
}
|
||||
|
||||
export const HamrahCarSchema = SchemaFactory.createForClass(HamrahCar);
|
||||
30
src/schemas/yadakmarket-part.schema.ts
Normal file
30
src/schemas/yadakmarket-part.schema.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
|
||||
import { HydratedDocument } from 'mongoose';
|
||||
|
||||
export type YadakmarketPartDocument = HydratedDocument<YadakmarketPart>;
|
||||
|
||||
@Schema({ timestamps: true })
|
||||
export class YadakmarketPart {
|
||||
@Prop({ required: true, index: true })
|
||||
productName!: string;
|
||||
|
||||
@Prop({ type: String, default: null })
|
||||
productUrl!: string | null;
|
||||
|
||||
@Prop({ type: String, default: null, index: true })
|
||||
productId!: string | null;
|
||||
|
||||
@Prop({ type: String, default: null })
|
||||
currentPrice!: string | null;
|
||||
|
||||
@Prop({ type: String, default: null })
|
||||
maxPrice!: string | null;
|
||||
|
||||
@Prop({ type: Date, default: null })
|
||||
priceChangedAt!: Date | null;
|
||||
|
||||
@Prop({ default: Date.now, index: true })
|
||||
crawledAt!: Date;
|
||||
}
|
||||
|
||||
export const YadakmarketPartSchema = SchemaFactory.createForClass(YadakmarketPart);
|
||||
Reference in New Issue
Block a user