update view mail report
This commit is contained in:
parent
58d4809548
commit
5fea257b68
|
|
@ -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