First version of new crawler
This commit is contained in:
126
.gitignore
vendored
126
.gitignore
vendored
@@ -1,58 +1,146 @@
|
|||||||
|
### Node ###
|
||||||
# Logs
|
# Logs
|
||||||
logs
|
logs
|
||||||
*.log
|
*.log
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||||
|
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||||
|
|
||||||
# Runtime data
|
# Runtime data
|
||||||
pids
|
pids
|
||||||
*.pid
|
*.pid
|
||||||
*.seed
|
*.seed
|
||||||
|
*.pid.lock
|
||||||
|
|
||||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||||
lib-cov
|
lib-cov
|
||||||
|
|
||||||
# Coverage directory used by tools like istanbul
|
# Coverage directory used by tools like istanbul
|
||||||
coverage
|
coverage
|
||||||
|
*.lcov
|
||||||
|
|
||||||
# nyc test coverage
|
# nyc test coverage
|
||||||
.nyc_output
|
.nyc_output
|
||||||
|
|
||||||
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
|
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||||
.grunt
|
.grunt
|
||||||
|
|
||||||
|
# Bower dependency directory (https://bower.io/)
|
||||||
|
bower_components
|
||||||
|
|
||||||
# node-waf configuration
|
# node-waf configuration
|
||||||
.lock-wscript
|
.lock-wscript
|
||||||
|
|
||||||
# Compiled binary addons (http://nodejs.org/api/addons.html)
|
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||||
build/Release
|
build/Release
|
||||||
|
|
||||||
# Dependency directories
|
# Dependency directories
|
||||||
node_modules
|
node_modules/
|
||||||
jspm_packages
|
jspm_packages/
|
||||||
|
|
||||||
|
# Snowpack dependency directory (https://snowpack.dev/)
|
||||||
|
web_modules/
|
||||||
|
|
||||||
|
# TypeScript cache
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
# Optional npm cache directory
|
# Optional npm cache directory
|
||||||
.npm
|
.npm
|
||||||
|
|
||||||
|
# Optional eslint cache
|
||||||
|
.eslintcache
|
||||||
|
|
||||||
|
# Optional stylelint cache
|
||||||
|
.stylelintcache
|
||||||
|
|
||||||
|
# Microbundle cache
|
||||||
|
.rpt2_cache/
|
||||||
|
.rts2_cache_cjs/
|
||||||
|
.rts2_cache_es/
|
||||||
|
.rts2_cache_umd/
|
||||||
|
|
||||||
# Optional REPL history
|
# Optional REPL history
|
||||||
.node_repl_history
|
.node_repl_history
|
||||||
|
|
||||||
# 0x
|
# Output of 'npm pack'
|
||||||
profile-*
|
*.tgz
|
||||||
|
|
||||||
# mac files
|
# Yarn Integrity file
|
||||||
.DS_Store
|
.yarn-integrity
|
||||||
|
|
||||||
# vim swap files
|
# dotenv environment variable files
|
||||||
*.swp
|
.env
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
.env.local
|
||||||
|
.env.development.env
|
||||||
|
# parcel-bundler cache (https://parceljs.org/)
|
||||||
|
.cache
|
||||||
|
.parcel-cache
|
||||||
|
|
||||||
# webstorm
|
# Next.js build output
|
||||||
.idea
|
.next
|
||||||
|
out
|
||||||
|
|
||||||
# vscode
|
# Nuxt.js build / generate output
|
||||||
.vscode
|
.nuxt
|
||||||
*code-workspace
|
dist
|
||||||
|
|
||||||
# clinic
|
# Gatsby files
|
||||||
profile*
|
.cache/
|
||||||
*clinic*
|
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||||
*flamegraph*
|
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||||
|
# public
|
||||||
|
|
||||||
|
# vuepress build output
|
||||||
|
.vuepress/dist
|
||||||
|
|
||||||
|
# vuepress v2.x temp and cache directory
|
||||||
|
.temp
|
||||||
|
|
||||||
|
# Docusaurus cache and generated files
|
||||||
|
.docusaurus
|
||||||
|
|
||||||
|
# Serverless directories
|
||||||
|
.serverless/
|
||||||
|
|
||||||
|
# FuseBox cache
|
||||||
|
.fusebox/
|
||||||
|
|
||||||
|
# DynamoDB Local files
|
||||||
|
.dynamodb/
|
||||||
|
|
||||||
|
# TernJS port file
|
||||||
|
.tern-port
|
||||||
|
|
||||||
|
# Stores VSCode versions used for testing VSCode extensions
|
||||||
|
.vscode-test
|
||||||
|
|
||||||
|
# yarn v2
|
||||||
|
.yarn/cache
|
||||||
|
.yarn/unplugged
|
||||||
|
.yarn/build-state.yml
|
||||||
|
.yarn/install-state.gz
|
||||||
|
.pnp.*
|
||||||
|
|
||||||
|
### Node Patch ###
|
||||||
|
# Serverless Webpack directories
|
||||||
|
.webpack/
|
||||||
|
|
||||||
|
# Optional stylelint cache
|
||||||
|
|
||||||
|
# SvelteKit build / generate output
|
||||||
|
.svelte-kit
|
||||||
|
|
||||||
|
# End of https://www.toptal.com/developers/gitignore/api/node
|
||||||
|
|
||||||
|
/docker
|
||||||
|
.development.env
|
||||||
|
*.env
|
||||||
|
/files
|
||||||
|
|||||||
23
README.md
23
README.md
@@ -1,23 +0,0 @@
|
|||||||
# Getting Started with [Fastify-CLI](https://www.npmjs.com/package/fastify-cli)
|
|
||||||
This project was bootstrapped with Fastify-CLI.
|
|
||||||
|
|
||||||
## Available Scripts
|
|
||||||
|
|
||||||
In the project directory, you can run:
|
|
||||||
|
|
||||||
### `npm run dev`
|
|
||||||
|
|
||||||
To start the app in dev mode.\
|
|
||||||
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
|
|
||||||
|
|
||||||
### `npm start`
|
|
||||||
|
|
||||||
For production mode
|
|
||||||
|
|
||||||
### `npm run test`
|
|
||||||
|
|
||||||
Run the test cases.
|
|
||||||
|
|
||||||
## Learn More
|
|
||||||
|
|
||||||
To learn Fastify, check out the [Fastify documentation](https://fastify.dev/docs/latest/).
|
|
||||||
30
app.js
30
app.js
@@ -1,30 +0,0 @@
|
|||||||
'use strict'
|
|
||||||
|
|
||||||
const path = require('node:path')
|
|
||||||
const AutoLoad = require('@fastify/autoload')
|
|
||||||
|
|
||||||
// Pass --options via CLI arguments in command to enable these options.
|
|
||||||
const options = {}
|
|
||||||
|
|
||||||
module.exports = async function (fastify, opts) {
|
|
||||||
// Place here your custom code!
|
|
||||||
|
|
||||||
// Do not touch the following lines
|
|
||||||
|
|
||||||
// This loads all plugins defined in plugins
|
|
||||||
// those should be support plugins that are reused
|
|
||||||
// through your application
|
|
||||||
fastify.register(AutoLoad, {
|
|
||||||
dir: path.join(__dirname, 'plugins'),
|
|
||||||
options: Object.assign({}, opts)
|
|
||||||
})
|
|
||||||
|
|
||||||
// This loads all plugins defined in routes
|
|
||||||
// define your routes in one of these
|
|
||||||
fastify.register(AutoLoad, {
|
|
||||||
dir: path.join(__dirname, 'routes'),
|
|
||||||
options: Object.assign({}, opts)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports.options = options
|
|
||||||
1
json/akharin/2026-1-7-akharin-cars.json
Normal file
1
json/akharin/2026-1-7-akharin-cars.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
[]
|
||||||
2167
json/hamrah/2026-1-7-hamrah-cars.json
Normal file
2167
json/hamrah/2026-1-7-hamrah-cars.json
Normal file
File diff suppressed because it is too large
Load Diff
19475
json/yadakmarket/2026-1-7-yadakmarket-parts.json
Normal file
19475
json/yadakmarket/2026-1-7-yadakmarket-parts.json
Normal file
File diff suppressed because it is too large
Load Diff
8
nest-cli.json
Normal file
8
nest-cli.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/nest-cli",
|
||||||
|
"collection": "@nestjs/schematics",
|
||||||
|
"sourceRoot": "src",
|
||||||
|
"compilerOptions": {
|
||||||
|
"deleteOutDir": true
|
||||||
|
}
|
||||||
|
}
|
||||||
5337
package-lock.json
generated
5337
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
38
package.json
38
package.json
@@ -2,25 +2,43 @@
|
|||||||
"name": "myproject",
|
"name": "myproject",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "This project was bootstrapped with Fastify-CLI.",
|
"description": "This project was bootstrapped with Fastify-CLI.",
|
||||||
"main": "app.js",
|
"main": "dist/main.js",
|
||||||
"directories": {
|
"directories": {
|
||||||
"test": "test"
|
"test": "test"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"build": "tsc -p tsconfig.build.json",
|
||||||
|
"start": "node dist/main.js",
|
||||||
|
"start:dev": "ts-node-dev --respawn --transpile-only -r tsconfig-paths/register src/main.ts",
|
||||||
|
"start:prod": "node dist/main.js",
|
||||||
"test": "node --test test/**/*.test.js",
|
"test": "node --test test/**/*.test.js",
|
||||||
"start": "fastify start -l info app.js",
|
"crawl": "ts-node -r tsconfig-paths/register src/cli/crawl.ts"
|
||||||
"dev": "fastify start -w -l info -P app.js"
|
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/autoload": "^5.0.0",
|
"@nestjs/common": "^11.1.12",
|
||||||
"@fastify/sensible": "^5.0.0",
|
"@nestjs/config": "^4.0.2",
|
||||||
"axios": "^1.9.0",
|
"@nestjs/core": "^11.1.12",
|
||||||
"fastify": "^4.26.1",
|
"@nestjs/mongoose": "^11.0.4",
|
||||||
"fastify-cli": "^6.1.1",
|
"@nestjs/platform-express": "^11.1.12",
|
||||||
"fastify-cron": "^1.4.0",
|
"@nestjs/schedule": "^6.1.0",
|
||||||
"fastify-plugin": "^4.0.0"
|
"@nestjs/swagger": "^11.2.5",
|
||||||
|
"axios": "^1.13.2",
|
||||||
|
"cheerio": "^1.2.0",
|
||||||
|
"class-transformer": "^0.5.1",
|
||||||
|
"class-validator": "^0.14.3",
|
||||||
|
"mongoose": "^9.1.5",
|
||||||
|
"reflect-metadata": "^0.2.2",
|
||||||
|
"rxjs": "^7.8.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@nestjs/cli": "^11.0.16",
|
||||||
|
"@types/node": "^25.0.10",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
|
"ts-node-dev": "^2.0.0",
|
||||||
|
"tsconfig-paths": "^4.2.0",
|
||||||
|
"typescript": "^5.9.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
# Plugins Folder
|
|
||||||
|
|
||||||
Plugins define behavior that is common to all the routes in your
|
|
||||||
application. Authentication, caching, templates, and all the other cross
|
|
||||||
cutting concerns should be handled by plugins placed in this folder.
|
|
||||||
|
|
||||||
Files in this folder are typically defined through the
|
|
||||||
[`fastify-plugin`](https://github.com/fastify/fastify-plugin) module,
|
|
||||||
making them non-encapsulated. They can define decorators and set hooks
|
|
||||||
that will then be used in the rest of your application.
|
|
||||||
|
|
||||||
Check out:
|
|
||||||
|
|
||||||
* [The hitchhiker's guide to plugins](https://fastify.dev/docs/latest/Guides/Plugins-Guide/)
|
|
||||||
* [Fastify decorators](https://fastify.dev/docs/latest/Reference/Decorators/).
|
|
||||||
* [Fastify lifecycle](https://fastify.dev/docs/latest/Reference/Lifecycle/).
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
'use strict'
|
|
||||||
|
|
||||||
const fp = require('fastify-plugin')
|
|
||||||
const fastifyCron = require('fastify-cron')
|
|
||||||
|
|
||||||
const cheerio = require('cheerio');
|
|
||||||
const { log } = require('console');
|
|
||||||
const fs = require('fs');
|
|
||||||
const axios = require('axios');
|
|
||||||
const akharin = 'https://akharinkhodro.ir/price/%D9%82%DB%8C%D9%85%D8%AA-%D8%AE%D9%88%D8%AF%D8%B1%D9%88/';
|
|
||||||
const hamrah = 'https://www.hamrah-mechanic.com/carprice/';
|
|
||||||
|
|
||||||
|
|
||||||
module.exports = fp(async function (fastify, opts) {
|
|
||||||
fastify.register(fastifyCron, {
|
|
||||||
jobs: [
|
|
||||||
{
|
|
||||||
cronTime: '0 0 * * *', ///'*/5 * * * * *'
|
|
||||||
onTick: async () => {
|
|
||||||
fastify.log.info('⏰ Cron Job ran at ' + new Date().toISOString())
|
|
||||||
try {
|
|
||||||
const { data } = await axios.get(akharin, {
|
|
||||||
headers: { 'User-Agent': 'Mozilla/5.0' },
|
|
||||||
});
|
|
||||||
|
|
||||||
const $ = cheerio.load(data);
|
|
||||||
const cars = [];
|
|
||||||
|
|
||||||
let td = $('tr')
|
|
||||||
let result = []
|
|
||||||
td.each((i, tr) => {
|
|
||||||
let listOfLine = $(tr).text().trim().split('\n')
|
|
||||||
if (listOfLine.length > 1) {
|
|
||||||
if (listOfLine[0] !== "نام خودرو") {
|
|
||||||
result.push({
|
|
||||||
carName: listOfLine[0],
|
|
||||||
marketPrice: listOfLine[1].trim().split(" ")[0] === "بدون" ? null : listOfLine[1].trim().split(" ")[0],
|
|
||||||
newCarPrice: listOfLine[2].trim().split(" ")[0]
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
fs.writeFileSync(`./json/akharin/${new Date().getFullYear()}-${new Date().getMonth()}-${new Date().getDay()}-akharin-cars.json`, JSON.stringify(result, null, 2), 'utf-8');
|
|
||||||
console.log(`${new Date().getFullYear()}-$${new Date().getMonth()}-${new Date().getDay()}-akharin-cars.json => new:`, cars.length);
|
|
||||||
|
|
||||||
} catch (err) {
|
|
||||||
console.error('crawler stop:', err.message);
|
|
||||||
} },
|
|
||||||
start: true
|
|
||||||
},{
|
|
||||||
cronTime: '0 0 * * *', ///'*/5 * * * * *'
|
|
||||||
onTick: async () => {
|
|
||||||
fastify.log.info('⏰ Cron Job ran at hamrah ' + new Date().toISOString())
|
|
||||||
try {
|
|
||||||
const { data } = await axios.get(hamrah, {
|
|
||||||
headers: { 'User-Agent': 'Mozilla/5.0' },
|
|
||||||
});
|
|
||||||
|
|
||||||
const $ = cheerio.load(data);
|
|
||||||
const cars = [];
|
|
||||||
const result = []
|
|
||||||
let sectionName = $('div.carsBrandPriceList_brand__name__Ohntn')
|
|
||||||
|
|
||||||
|
|
||||||
let tables = $('tbody tr');
|
|
||||||
|
|
||||||
tables.each((i, tr) => {
|
|
||||||
const tds = $(tr).find('td');
|
|
||||||
const modelDivs = $(tds[0]).find('div');
|
|
||||||
const priceDivs = $(tds[1]).find('div');
|
|
||||||
|
|
||||||
const carName = $(modelDivs[0]).text().trim(); // مدل اصلی مثل Q5 E-tron
|
|
||||||
const typeAndYear = $(modelDivs[1]).text().replace(/\s+/g, ' ').trim(); // تیپ 40- فول-2024
|
|
||||||
|
|
||||||
const marketPrice = $(priceDivs[0]).text().trim(); // قیمت
|
|
||||||
result.push({carName , typeAndYear , marketPrice})
|
|
||||||
});
|
|
||||||
|
|
||||||
// ذخیرهسازی در فایل JSON
|
|
||||||
fs.writeFileSync(`./json/hamrah/${new Date().getFullYear()}-${new Date().getMonth()}-${new Date().getDay()}-hamrah-cars.json`, JSON.stringify(result, null, 2), 'utf-8');
|
|
||||||
console.log(`${new Date().getFullYear()}-$${new Date().getMonth()}-${new Date().getDay()}-akharin-cars.json => new:`, cars.length);
|
|
||||||
|
|
||||||
|
|
||||||
} catch (err) {
|
|
||||||
console.error('❌ خطا در کراول:', err.message);
|
|
||||||
} },
|
|
||||||
start: true
|
|
||||||
}
|
|
||||||
]
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
'use strict'
|
|
||||||
|
|
||||||
const fp = require('fastify-plugin')
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This plugins adds some utilities to handle http errors
|
|
||||||
*
|
|
||||||
* @see https://github.com/fastify/fastify-sensible
|
|
||||||
*/
|
|
||||||
module.exports = fp(async function (fastify, opts) {
|
|
||||||
fastify.register(require('@fastify/sensible'), {
|
|
||||||
errorHandler: false
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
'use strict'
|
|
||||||
|
|
||||||
const fp = require('fastify-plugin')
|
|
||||||
|
|
||||||
// the use of fastify-plugin is required to be able
|
|
||||||
// to export the decorators to the outer scope
|
|
||||||
|
|
||||||
module.exports = fp(async function (fastify, opts) {
|
|
||||||
fastify.decorate('someSupport', function () {
|
|
||||||
return 'hugs'
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
# Routes Folder
|
|
||||||
|
|
||||||
Routes define routes within your application. Fastify provides an
|
|
||||||
easy path to a microservice architecture, in the future you might want
|
|
||||||
to independently deploy some of those.
|
|
||||||
|
|
||||||
In this folder you should define all the routes that define the endpoints
|
|
||||||
of your web application.
|
|
||||||
Each service is a [Fastify
|
|
||||||
plugin](https://fastify.dev/docs/latest/Reference/Plugins/), it is
|
|
||||||
encapsulated (it can have its own independent plugins) and it is
|
|
||||||
typically stored in a file; be careful to group your routes logically,
|
|
||||||
e.g. all `/users` routes in a `users.js` file. We have added
|
|
||||||
a `root.js` file for you with a '/' root added.
|
|
||||||
|
|
||||||
If a single file become too large, create a folder and add a `index.js` file there:
|
|
||||||
this file must be a Fastify plugin, and it will be loaded automatically
|
|
||||||
by the application. You can now add as many files as you want inside that folder.
|
|
||||||
In this way you can create complex routes within a single monolith,
|
|
||||||
and eventually extract them.
|
|
||||||
|
|
||||||
If you need to share functionality between routes, place that
|
|
||||||
functionality into the `plugins` folder, and share it via
|
|
||||||
[decorators](https://fastify.dev/docs/latest/Reference/Decorators/).
|
|
||||||
|
|
||||||
If you're a bit confused about using `async/await` to write routes, you would
|
|
||||||
better take a look at [Promise resolution](https://fastify.dev/docs/latest/Reference/Routes/#promise-resolution) for more details.
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
'use strict'
|
|
||||||
|
|
||||||
module.exports = async function (fastify, opts) {
|
|
||||||
fastify.get('/', async function (request, reply) {
|
|
||||||
return 'this is an example'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
'use strict'
|
|
||||||
|
|
||||||
const { log } = require("console")
|
|
||||||
const {readFile , readdir} = require('fs/promises')
|
|
||||||
module.exports = async function (fastify, opts) {
|
|
||||||
fastify.get('/price', async function (request, reply) {
|
|
||||||
if(Object.keys(request.query).length == 0) return reply.status(500).send({ ok: false, message:'use query params' , enums:['akharin' , 'hamrah'] })
|
|
||||||
|
|
||||||
|
|
||||||
let dir = await readdir(`${process.cwd()}/json/${Object.keys(request.query)[0]}`)
|
|
||||||
let file = await readFile(`${process.cwd()}/json/${Object.keys(request.query)[0]}/${dir[0]}` , 'utf-8')
|
|
||||||
return JSON.parse(file)
|
|
||||||
})
|
|
||||||
|
|
||||||
}
|
|
||||||
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);
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
'use strict'
|
|
||||||
|
|
||||||
// This file contains code that we reuse
|
|
||||||
// between our tests.
|
|
||||||
|
|
||||||
const { build: buildApplication } = require('fastify-cli/helper')
|
|
||||||
const path = require('node:path')
|
|
||||||
const AppPath = path.join(__dirname, '..', 'app.js')
|
|
||||||
|
|
||||||
// Fill in this config with all the configurations
|
|
||||||
// needed for testing the application
|
|
||||||
function config () {
|
|
||||||
return {}
|
|
||||||
}
|
|
||||||
|
|
||||||
// automatically build and tear down our instance
|
|
||||||
async function build (t) {
|
|
||||||
// you can set all the options supported by the fastify CLI command
|
|
||||||
const argv = [AppPath]
|
|
||||||
|
|
||||||
// fastify-plugin ensures that all decorators
|
|
||||||
// are exposed for testing purposes, this is
|
|
||||||
// different from the production setup
|
|
||||||
const app = await buildApplication(argv, config())
|
|
||||||
|
|
||||||
// close the app after we are done
|
|
||||||
t.after(() => app.close())
|
|
||||||
|
|
||||||
return app
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
config,
|
|
||||||
build
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
'use strict'
|
|
||||||
|
|
||||||
const { test } = require('node:test')
|
|
||||||
const assert = require('node:assert')
|
|
||||||
|
|
||||||
const Fastify = require('fastify')
|
|
||||||
const Support = require('../../plugins/support')
|
|
||||||
|
|
||||||
test('support works standalone', async (t) => {
|
|
||||||
const fastify = Fastify()
|
|
||||||
fastify.register(Support)
|
|
||||||
|
|
||||||
await fastify.ready()
|
|
||||||
assert.equal(fastify.someSupport(), 'hugs')
|
|
||||||
})
|
|
||||||
|
|
||||||
// You can also use plugin with opts in fastify v2
|
|
||||||
//
|
|
||||||
// test('support works standalone', (t) => {
|
|
||||||
// t.plan(2)
|
|
||||||
// const fastify = Fastify()
|
|
||||||
// fastify.register(Support)
|
|
||||||
//
|
|
||||||
// fastify.ready((err) => {
|
|
||||||
// t.error(err)
|
|
||||||
// assert.equal(fastify.someSupport(), 'hugs')
|
|
||||||
// })
|
|
||||||
// })
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
'use strict'
|
|
||||||
|
|
||||||
const { test } = require('node:test')
|
|
||||||
const assert = require('node:assert')
|
|
||||||
const { build } = require('../helper')
|
|
||||||
|
|
||||||
test('example is loaded', async (t) => {
|
|
||||||
const app = await build(t)
|
|
||||||
|
|
||||||
const res = await app.inject({
|
|
||||||
url: '/example'
|
|
||||||
})
|
|
||||||
assert.equal(res.payload, 'this is an example')
|
|
||||||
})
|
|
||||||
|
|
||||||
// inject callback style:
|
|
||||||
//
|
|
||||||
// test('example is loaded', (t) => {
|
|
||||||
// t.plan(2)
|
|
||||||
// const app = await build(t)
|
|
||||||
//
|
|
||||||
// app.inject({
|
|
||||||
// url: '/example'
|
|
||||||
// }, (err, res) => {
|
|
||||||
// t.error(err)
|
|
||||||
// assert.equal(res.payload, 'this is an example')
|
|
||||||
// })
|
|
||||||
// })
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
'use strict'
|
|
||||||
|
|
||||||
const { test } = require('node:test')
|
|
||||||
const assert = require('node:assert')
|
|
||||||
const { build } = require('../helper')
|
|
||||||
|
|
||||||
test('default root route', async (t) => {
|
|
||||||
const app = await build(t)
|
|
||||||
|
|
||||||
const res = await app.inject({
|
|
||||||
url: '/'
|
|
||||||
})
|
|
||||||
assert.deepStrictEqual(JSON.parse(res.payload), { root: true })
|
|
||||||
})
|
|
||||||
|
|
||||||
// inject callback style:
|
|
||||||
//
|
|
||||||
// test('default root route', (t) => {
|
|
||||||
// t.plan(2)
|
|
||||||
// const app = await build(t)
|
|
||||||
//
|
|
||||||
// app.inject({
|
|
||||||
// url: '/'
|
|
||||||
// }, (err, res) => {
|
|
||||||
// t.error(err)
|
|
||||||
// assert.deepStrictEqual(JSON.parse(res.payload), { root: true })
|
|
||||||
// })
|
|
||||||
// })
|
|
||||||
14
tsconfig.build.json
Normal file
14
tsconfig.build.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"declaration": true,
|
||||||
|
"sourceMap": false
|
||||||
|
},
|
||||||
|
"exclude": [
|
||||||
|
"node_modules",
|
||||||
|
"dist",
|
||||||
|
"test",
|
||||||
|
"**/*.spec.ts",
|
||||||
|
"src/cli/**/*.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
19
tsconfig.json
Normal file
19
tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "commonjs",
|
||||||
|
"declaration": true,
|
||||||
|
"removeComments": true,
|
||||||
|
"emitDecoratorMetadata": true,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"target": "ES2021",
|
||||||
|
"sourceMap": true,
|
||||||
|
"outDir": "./dist",
|
||||||
|
"baseUrl": "./",
|
||||||
|
"incremental": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"],
|
||||||
|
"exclude": ["node_modules", "dist", "test", "**/*.spec.ts"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user