Deploy to production #68
			
				
			
		
		
		
	| 
						 | 
				
			
			@ -117,7 +117,7 @@ export default function MailsConfig() {
 | 
			
		|||
  };
 | 
			
		||||
 | 
			
		||||
  const handleAdd = async (mail: string) => {
 | 
			
		||||
    const newMails = [...mails, mail];
 | 
			
		||||
    const newMails = [...mails, mail.trim()];
 | 
			
		||||
 | 
			
		||||
    open();
 | 
			
		||||
    const response = await upsertConfig({
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1 +1 @@
 | 
			
		|||
{"createdAt":1748224707802}
 | 
			
		||||
{"createdAt":1748825131474}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,31 @@
 | 
			
		|||
import { Injectable } from '@nestjs/common';
 | 
			
		||||
import { ConfigService } from '@nestjs/config';
 | 
			
		||||
import axios from 'axios';
 | 
			
		||||
import { BidsService } from '../services/bids.service';
 | 
			
		||||
 | 
			
		||||
@Injectable()
 | 
			
		||||
export class HotItemApi {
 | 
			
		||||
  constructor(
 | 
			
		||||
    private readonly bidsService: BidsService,
 | 
			
		||||
    private readonly configService: ConfigService,
 | 
			
		||||
  ) {}
 | 
			
		||||
 | 
			
		||||
  listHotItem = async () => {
 | 
			
		||||
    try {
 | 
			
		||||
      const res = await axios({
 | 
			
		||||
        method: 'GET',
 | 
			
		||||
        baseURL: this.configService.get('NEW_ITEM_BASE_URL'),
 | 
			
		||||
        url: '/disti/api/hotitem',
 | 
			
		||||
        headers: {
 | 
			
		||||
          //   ...axios.defaults.headers.common,
 | 
			
		||||
          'Content-Type': 'application/json',
 | 
			
		||||
          Authorization: 'Bearer ' + this.configService.get('NEW_ITEM_TOKEN'),
 | 
			
		||||
        },
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      return res.data.data || [];
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      return [];
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -33,6 +33,7 @@ import { Config } from './entities/configs.entity';
 | 
			
		|||
import { AdminConfigsController } from './controllers/admin/admin-configs.controller';
 | 
			
		||||
import { BidMetadatasService } from './services/bid-metadatas.service';
 | 
			
		||||
import { BidMetadata } from './entities/bid-metadata.entity';
 | 
			
		||||
import { HotItemApi } from './apis/hot-item.api';
 | 
			
		||||
 | 
			
		||||
@Module({
 | 
			
		||||
  imports: [
 | 
			
		||||
| 
						 | 
				
			
			@ -76,6 +77,7 @@ import { BidMetadata } from './entities/bid-metadata.entity';
 | 
			
		|||
    TasksService,
 | 
			
		||||
    ConfigsService,
 | 
			
		||||
    BidMetadatasService,
 | 
			
		||||
    HotItemApi,
 | 
			
		||||
  ],
 | 
			
		||||
  exports: [
 | 
			
		||||
    BotTelegramApi,
 | 
			
		||||
| 
						 | 
				
			
			@ -83,6 +85,7 @@ import { BidMetadata } from './entities/bid-metadata.entity';
 | 
			
		|||
    BidsService,
 | 
			
		||||
    ConfigsService,
 | 
			
		||||
    DashboardService,
 | 
			
		||||
    HotItemApi,
 | 
			
		||||
  ],
 | 
			
		||||
})
 | 
			
		||||
export class BidsModule {}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -98,7 +98,8 @@ export class ImapService {
 | 
			
		|||
            const result = verifyCode(emailContent);
 | 
			
		||||
 | 
			
		||||
            if (!result) {
 | 
			
		||||
              throw new Error('fetchLatestEmail: Name or Code is empty');
 | 
			
		||||
              console.log('fetchLatestEmail: Name or Code is empty');
 | 
			
		||||
              return;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            const { code, name } = result;
 | 
			
		||||
| 
						 | 
				
			
			@ -108,7 +109,8 @@ export class ImapService {
 | 
			
		|||
            });
 | 
			
		||||
 | 
			
		||||
            if (!webBid) {
 | 
			
		||||
              throw new Error('Not found web bid');
 | 
			
		||||
              console.log('Not found web bid');
 | 
			
		||||
              return;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            // send message event
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,15 +1,17 @@
 | 
			
		|||
import { Injectable } from '@nestjs/common';
 | 
			
		||||
import { MailerService } from '@nestjs-modules/mailer';
 | 
			
		||||
import { Bid } from '@/modules/bids/entities/bid.entity';
 | 
			
		||||
import { ScrapItem } from '@/modules/scraps/entities/scrap-item.entity';
 | 
			
		||||
import {
 | 
			
		||||
  extractDomain,
 | 
			
		||||
  extractDomainSmart,
 | 
			
		||||
  formatEndTime,
 | 
			
		||||
  isHotItemFn,
 | 
			
		||||
  isTimeReached,
 | 
			
		||||
} from '@/ultils';
 | 
			
		||||
import { Bid } from '@/modules/bids/entities/bid.entity';
 | 
			
		||||
import { MailerService } from '@nestjs-modules/mailer';
 | 
			
		||||
import { InjectQueue } from '@nestjs/bull';
 | 
			
		||||
import { Injectable } from '@nestjs/common';
 | 
			
		||||
import { Queue } from 'bull';
 | 
			
		||||
import * as _ from 'lodash';
 | 
			
		||||
 | 
			
		||||
@Injectable()
 | 
			
		||||
export class MailsService {
 | 
			
		||||
| 
						 | 
				
			
			@ -52,17 +54,26 @@ export class MailsService {
 | 
			
		|||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  generateProductTableHTML(products: ScrapItem[]): string {
 | 
			
		||||
  generateProductTableHTML(
 | 
			
		||||
    products: ScrapItem[],
 | 
			
		||||
    hotItems: { name: string }[],
 | 
			
		||||
  ): {
 | 
			
		||||
    html: string;
 | 
			
		||||
    hasHotItem: boolean;
 | 
			
		||||
  } {
 | 
			
		||||
    const from = process.env.MAIL_USER || 'no-reply@example.com';
 | 
			
		||||
 | 
			
		||||
    let hasHotItem = false;
 | 
			
		||||
 | 
			
		||||
    if (!products.length) {
 | 
			
		||||
      return `
 | 
			
		||||
      return {
 | 
			
		||||
        html: `
 | 
			
		||||
    <!DOCTYPE html>
 | 
			
		||||
    <html lang="en">
 | 
			
		||||
    <head>
 | 
			
		||||
      <meta charset="UTF-8" />
 | 
			
		||||
      <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
 | 
			
		||||
      <title>Products</title>
 | 
			
		||||
      <title>Product Listing</title>
 | 
			
		||||
    </head>
 | 
			
		||||
    <body style="font-family: sans-serif; background: #f8f9fa; padding: 20px;">
 | 
			
		||||
      <h2 style="text-align: center; color: #333;">Product Listing</h2>
 | 
			
		||||
| 
						 | 
				
			
			@ -70,33 +81,75 @@ export class MailsService {
 | 
			
		|||
      <p style="text-align: center; color: #999; font-size: 12px; margin-top: 40px;">From: ${from}</p>
 | 
			
		||||
    </body>
 | 
			
		||||
    </html>
 | 
			
		||||
    `;
 | 
			
		||||
    `,
 | 
			
		||||
        hasHotItem,
 | 
			
		||||
      };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const rows = products
 | 
			
		||||
      .map(
 | 
			
		||||
        (p) => `
 | 
			
		||||
      <tr>
 | 
			
		||||
        <td><img src="${p.image_url}" alt="Product Image" style="height: 40px; object-fit: contain; border-radius: 4px;" /></td>
 | 
			
		||||
        <td>${p.name}</td>
 | 
			
		||||
        <td style="font-weight: bold; color: #e03131;">${p.current_price ? '$' + p.current_price : 'None'}</td>
 | 
			
		||||
        <td><a href="${p.url}" target="_blank" style="color: #007bff;">View</a></td>
 | 
			
		||||
        <td>${extractDomainSmart(p.scrap_config.web_bid.origin_url)}</td>
 | 
			
		||||
      </tr>
 | 
			
		||||
    `,
 | 
			
		||||
      )
 | 
			
		||||
    // Decorate products with isHotItem
 | 
			
		||||
    const decorated = products.map((p) => {
 | 
			
		||||
      const isHotItem = hotItems.some((obj) =>
 | 
			
		||||
        p.name.toLowerCase().includes(obj.name.toLowerCase()),
 | 
			
		||||
      );
 | 
			
		||||
      if (isHotItem) hasHotItem = true;
 | 
			
		||||
 | 
			
		||||
      return {
 | 
			
		||||
        ...p,
 | 
			
		||||
        isHotItem,
 | 
			
		||||
        isNew:
 | 
			
		||||
          new Date(p.created_at).getTime() === new Date(p.updated_at).getTime(),
 | 
			
		||||
      };
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    // Separate hot and non-hot items, preserving original order
 | 
			
		||||
    const hotItemsFirst = [
 | 
			
		||||
      ...decorated.filter((p) => p.isHotItem),
 | 
			
		||||
      ...decorated.filter((p) => !p.isHotItem),
 | 
			
		||||
    ];
 | 
			
		||||
 | 
			
		||||
    const rows = hotItemsFirst
 | 
			
		||||
      .map((p) => {
 | 
			
		||||
        const isHotItem = isHotItemFn(p, hotItems);
 | 
			
		||||
 | 
			
		||||
        if (isHotItem) {
 | 
			
		||||
          hasHotItem = true;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const isNew =
 | 
			
		||||
          new Date(p.created_at).getTime() === new Date(p.updated_at).getTime();
 | 
			
		||||
 | 
			
		||||
        const nameStyle = isNew ? 'color: #2f9e44; font-weight: bold;' : '';
 | 
			
		||||
        const namePrefix = isHotItem
 | 
			
		||||
          ? '<span style="font-weight: bold; color: #e03131;">[HOT ITEM]</span> '
 | 
			
		||||
          : '';
 | 
			
		||||
 | 
			
		||||
        return `
 | 
			
		||||
        <tr ${isHotItem ? 'style="background-color: #ff848496;margin-bottom: 4px;"' : 'margin-bottom: 4px;'}>
 | 
			
		||||
          <td style="display:flex;max-width: 60px;"><img src="${p.image_url}" alt="Product Image" style="height: 60px !important; object-fit: contain; border-radius: 4px;" /></td>
 | 
			
		||||
          <td style="${nameStyle}">${namePrefix}${p.name}</td>
 | 
			
		||||
          <td style="font-weight: bold; color: #e03131;">${p.current_price ? '$' + p.current_price : 'None'}</td>
 | 
			
		||||
          <td><a href="${p.url}" target="_blank" style="color: #007bff;">View</a></td>
 | 
			
		||||
          <td>${extractDomainSmart(p.scrap_config.web_bid.origin_url)}</td>
 | 
			
		||||
        </tr>
 | 
			
		||||
      `;
 | 
			
		||||
      })
 | 
			
		||||
      .join('');
 | 
			
		||||
 | 
			
		||||
    return `
 | 
			
		||||
    const title = hasHotItem
 | 
			
		||||
      ? '<span style="font-weight: bold; color: #e03131;">[HOT ITEMS]</span> Product Listing'
 | 
			
		||||
      : 'Product Listing';
 | 
			
		||||
 | 
			
		||||
    return {
 | 
			
		||||
      html: `
 | 
			
		||||
  <!DOCTYPE html>
 | 
			
		||||
  <html lang="en">
 | 
			
		||||
  <head>
 | 
			
		||||
    <meta charset="UTF-8" />
 | 
			
		||||
    <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
 | 
			
		||||
    <title>Products</title>
 | 
			
		||||
    <title>${title}</title>
 | 
			
		||||
  </head>
 | 
			
		||||
  <body style="font-family: sans-serif; background: #f8f9fa; padding: 20px;">
 | 
			
		||||
    <h2 style="text-align: center; color: #333;">Product Listing</h2>
 | 
			
		||||
    <h2 style="text-align: center; color: #333;">${title}</h2>
 | 
			
		||||
    <div style="overflow-x: auto;">
 | 
			
		||||
      <table style="width: 100%; min-width: 500px; border-collapse: collapse; background: #fff; border-radius: 6px; box-shadow: 0 2px 6px rgba(0,0,0,0.05);">
 | 
			
		||||
        <thead style="background: #f1f3f5;">
 | 
			
		||||
| 
						 | 
				
			
			@ -116,7 +169,9 @@ export class MailsService {
 | 
			
		|||
    <p style="text-align: center; color: #999; font-size: 12px; margin-top: 40px;">From: ${from}</p>
 | 
			
		||||
  </body>
 | 
			
		||||
  </html>
 | 
			
		||||
  `;
 | 
			
		||||
  `,
 | 
			
		||||
      hasHotItem,
 | 
			
		||||
    };
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  getAuctionStatusEmailContent(bid: Bid): string {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -3,10 +3,11 @@ import { DashboardService } from '@/modules/bids/services/dashboard.service';
 | 
			
		|||
import { MailsService } from '@/modules/mails/services/mails.service';
 | 
			
		||||
import { delay } from '@/ultils';
 | 
			
		||||
import { Injectable, Logger } from '@nestjs/common';
 | 
			
		||||
import { Cron } from '@nestjs/schedule';
 | 
			
		||||
import { Cron, CronExpression } from '@nestjs/schedule';
 | 
			
		||||
import * as moment from 'moment';
 | 
			
		||||
import { Between } from 'typeorm';
 | 
			
		||||
import { ScrapItemsService } from './scrap-item-config.service';
 | 
			
		||||
import { HotItemApi } from '@/modules/bids/apis/hot-item.api';
 | 
			
		||||
 | 
			
		||||
@Injectable()
 | 
			
		||||
export class TasksService {
 | 
			
		||||
| 
						 | 
				
			
			@ -17,6 +18,7 @@ export class TasksService {
 | 
			
		|||
    private readonly mailsService: MailsService,
 | 
			
		||||
    private readonly configsSerivce: ConfigsService,
 | 
			
		||||
    private readonly dashboardService: DashboardService,
 | 
			
		||||
    private readonly hotItemApi: HotItemApi,
 | 
			
		||||
  ) {}
 | 
			
		||||
 | 
			
		||||
  async runProcessAndSendReport(processName: string) {
 | 
			
		||||
| 
						 | 
				
			
			@ -71,13 +73,20 @@ export class TasksService {
 | 
			
		|||
          updated_at: Between(startOfDay, endOfDay),
 | 
			
		||||
        },
 | 
			
		||||
        relations: { scrap_config: { web_bid: true } },
 | 
			
		||||
        order: { updated_at: 'DESC' },
 | 
			
		||||
        order: { updated_at: 'ASC' },
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      const hotItems = await this.hotItemApi.listHotItem();
 | 
			
		||||
 | 
			
		||||
      const { hasHotItem, html } = this.mailsService.generateProductTableHTML(
 | 
			
		||||
        data,
 | 
			
		||||
        hotItems,
 | 
			
		||||
      );
 | 
			
		||||
 | 
			
		||||
      await this.mailsService.sendHtmlMailJob({
 | 
			
		||||
        to: mails,
 | 
			
		||||
        subject: `Auction Items Matching Your Keywords – Daily Update ${moment().format('YYYY-MM-DD HH:mm')}`,
 | 
			
		||||
        html: this.mailsService.generateProductTableHTML(data),
 | 
			
		||||
        subject: `${hasHotItem ? '[HOT ITEMS] ' : ''}Auction Items Matching Your Keywords – Daily Update ${moment().format('YYYY-MM-DD HH:mm')}`,
 | 
			
		||||
        html: html,
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      console.log('Report mail sent successfully.');
 | 
			
		||||
| 
						 | 
				
			
			@ -86,7 +95,8 @@ export class TasksService {
 | 
			
		|||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @Cron('58 5 * * *')
 | 
			
		||||
  // @Cron('59 5 * * *')
 | 
			
		||||
  // @Cron(CronExpression.EVERY_MINUTE)
 | 
			
		||||
  async handleScraps() {
 | 
			
		||||
    const processName = 'scrape-data-keyword';
 | 
			
		||||
    await this.runProcessAndSendReport(processName);
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,4 +1,5 @@
 | 
			
		|||
import { Bid } from '@/modules/bids/entities/bid.entity';
 | 
			
		||||
import { ScrapItem } from '@/modules/scraps/entities/scrap-item.entity';
 | 
			
		||||
import * as moment from 'moment';
 | 
			
		||||
 | 
			
		||||
export function extractModelId(url: string): string | null {
 | 
			
		||||
| 
						 | 
				
			
			@ -215,3 +216,9 @@ export function formatEndTime(
 | 
			
		|||
 | 
			
		||||
export const delay = (ms: number) =>
 | 
			
		||||
  new Promise((resolve) => setTimeout(resolve, ms));
 | 
			
		||||
 | 
			
		||||
export const isHotItemFn = (item: ScrapItem, hotItems: any[]) => {
 | 
			
		||||
  return hotItems.some((obj) =>
 | 
			
		||||
    item.name.toLowerCase().includes(obj.name.toLowerCase()),
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -22,6 +22,7 @@ const init = async () => {
 | 
			
		|||
      try {
 | 
			
		||||
        await model.action();
 | 
			
		||||
 | 
			
		||||
        console.log(`Loaded model [${model?.web_bid?.origin_url || ""}]`);
 | 
			
		||||
        for (const key of Object.keys(model.results)) {
 | 
			
		||||
          const dataArray = model.results[key];
 | 
			
		||||
          const result = await upsertScapeItems(dataArray);
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,25 +1,12 @@
 | 
			
		|||
import axios from "axios";
 | 
			
		||||
import browser from "../system/browser.js";
 | 
			
		||||
import { extractModelId, extractNumber } from "../system/ultils.js";
 | 
			
		||||
import { extractModelId } from "../system/ultils.js";
 | 
			
		||||
import { ScrapModel } from "./scrap-model.js";
 | 
			
		||||
 | 
			
		||||
export class AllbidsScrapModel extends ScrapModel {
 | 
			
		||||
  action = async () => {
 | 
			
		||||
    const urlsData = this.extractUrls();
 | 
			
		||||
 | 
			
		||||
    console.log({ urlsData });
 | 
			
		||||
 | 
			
		||||
    for (let item of urlsData) {
 | 
			
		||||
      // await this.page.goto(item.url);
 | 
			
		||||
 | 
			
		||||
      // const data = await this.getItemsInHtml(item);
 | 
			
		||||
 | 
			
		||||
      // const results = this.filterItemByKeyword(item.keyword, data);
 | 
			
		||||
 | 
			
		||||
      // this.results[item.keyword] = results;
 | 
			
		||||
 | 
			
		||||
      // console.log({ results: this.results });
 | 
			
		||||
 | 
			
		||||
      const { data } = await axios({
 | 
			
		||||
        url: item.url,
 | 
			
		||||
        method: "POST",
 | 
			
		||||
| 
						 | 
				
			
			@ -37,14 +24,14 @@ export class AllbidsScrapModel extends ScrapModel {
 | 
			
		|||
 | 
			
		||||
      const auctions = data?.auctions || [];
 | 
			
		||||
 | 
			
		||||
      const mappedData = auctions.map((item) => {
 | 
			
		||||
      const mappedData = auctions.map((auction) => {
 | 
			
		||||
        return {
 | 
			
		||||
          url: item.AucDetailsUrlLink,
 | 
			
		||||
          image_url: item.aucThumbnailUrl,
 | 
			
		||||
          name: item.aucTitle,
 | 
			
		||||
          keyword: data.keyword,
 | 
			
		||||
          model: extractModelId(item.AucDetailsUrlLink),
 | 
			
		||||
          current_price: item.aucCurrentBid,
 | 
			
		||||
          url: auction.AucDetailsUrlLink,
 | 
			
		||||
          image_url: auction.aucThumbnailUrl,
 | 
			
		||||
          name: auction.aucTitle,
 | 
			
		||||
          keyword: item.keyword,
 | 
			
		||||
          model: extractModelId(auction.AucDetailsUrlLink),
 | 
			
		||||
          current_price: auction.aucCurrentBid,
 | 
			
		||||
          scrap_config_id: this.scrap_config_id,
 | 
			
		||||
        };
 | 
			
		||||
      });
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
		Reference in New Issue