First version of new crawler

This commit is contained in:
SepehrYahyaee
2026-03-14 14:07:52 +03:30
parent abc0bd9bee
commit c9cf830d7b
35 changed files with 27108 additions and 1174 deletions

52
src/app.module.ts Normal file
View 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
View 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);
});

View 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' };
}
}

View 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(),
};
}
}

View 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'],
});
}
}
}

View 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,
};
}
}

View 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,
},
};
}
}

View 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,
};
}
}

View 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
View 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();

View 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);

View 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);

View 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);