This commit is contained in:
nkhangg 2025-03-18 16:29:06 +07:00
parent efd8cd1fe8
commit ec9b9506a6
43 changed files with 711 additions and 271 deletions

View File

@ -0,0 +1,17 @@
import { handleError } from '.';
import axios from '../lib/axios';
import { IBid } from '../system/type';
export const getDetailBidHistories = async (lot_id: IBid['lot_id']) => {
try {
const { data } = await axios({
url: `bid-histories/detail/${lot_id}`,
withCredentials: true,
method: 'GET',
});
return data;
} catch (error) {
handleError(error);
}
};

View File

@ -32,14 +32,14 @@ export const createBid = async (bid: Omit<IBid, 'id' | 'created_at' | 'updated_a
};
export const updateBid = async (bid: Partial<IBid>) => {
const { step_price, max_price, quantity } = removeFalsyValues(bid);
const { plus_price, max_price, quantity } = removeFalsyValues(bid, ['plus_price']);
try {
const { data } = await axios({
url: 'bids/' + bid.id,
withCredentials: true,
method: 'PUT',
data: { step_price, max_price, quantity },
data: { plus_price, max_price, quantity },
});
handleSuccess(data);

View File

@ -15,7 +15,7 @@ export interface IBidModelProps extends ModalProps {
const schema = {
url: z.string({ message: 'Url is required' }).url('Invalid url format'),
max_price: z.number({ message: 'Max price is required' }).min(1, 'Max price must be at least 1'),
step_price: z.number().min(0, 'Step price must be at least 1').optional(),
plus_price: z.number().min(0, 'Plus price must be at least 1').optional(),
quantity: z.number().min(1, 'Quantity must be at least 1').optional(),
};
@ -50,9 +50,9 @@ export default function BidModal({ data, onUpdated, ...props }: IBidModelProps)
},
});
} else {
const { url, max_price, step_price } = values;
const { url, max_price, plus_price } = values;
const result = await createBid({ url, max_price, step_price } as IBid);
const result = await createBid({ url, max_price, plus_price } as IBid);
if (!result) return;
@ -92,9 +92,10 @@ export default function BidModal({ data, onUpdated, ...props }: IBidModelProps)
centered
>
<form onSubmit={form.onSubmit(handleSubmit)} className="grid grid-cols-2 gap-2.5">
{data && data.name && <TextInput className="col-span-2" readOnly={!!data} size="sm" label="Name" value={data.name} />}
<TextInput className="col-span-2" readOnly={!!data} size="sm" label="Url" {...form.getInputProps('url')} />
<NumberInput className="col-span-2" size="sm" label="Max price" {...form.getInputProps('max_price')} />
<NumberInput size="sm" label="Step price" {...form.getInputProps('step_price')} />
<NumberInput size="sm" label="Plus price" {...form.getInputProps('plus_price')} />
<NumberInput size="sm" label="Quantity" {...form.getInputProps('quantity')} />
<Button disabled={_.isEqual(form.getValues(), prevData.current)} className="col-span-2" type="submit" fullWidth size="sm" mt="md">

View File

@ -1,2 +1,3 @@
export { default as ShowHistoriesModal } from './show-histories-modal';
export { default as ShowHistoriesBidGraysApiModal } from './show-histories-bid-grays-api-modal';
export { default as BidModal } from './bid-modal';

View File

@ -0,0 +1,74 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { Modal, ModalProps, Table } from '@mantine/core';
import axios from 'axios';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { extractNumber } from '../../lib/table/ultils';
import { IBid } from '../../system/type';
import { formatTime } from '../../utils';
import { getDetailBidHistories } from '../../apis/bid-histories';
export interface IShowHistoriesBidGraysApiModalProps extends ModalProps {
data: IBid | null;
onUpdated?: () => void;
}
export default function ShowHistoriesBidGraysApiModal({ data, onUpdated, ...props }: IShowHistoriesBidGraysApiModalProps) {
const [histories, setHistories] = useState<Record<string, string>[]>([]);
const rows = useMemo(() => {
return histories.map((element) => (
<Table.Tr key={element.LotId}>
<Table.Td>{`${element['UserInitials']} - ${element['UserShortAddress']}`}</Table.Td>
<Table.Td>{formatTime(new Date(extractNumber(element['OriginalDate']) || 0).toUTCString(), 'HH:mm:ss DD/MM/YYYY')}</Table.Td>
<Table.Td>{`AU $${element['Price']}`}</Table.Td>
<Table.Td>{`${element['Quantity']}`}</Table.Td>
<Table.Td>{`${element['WinningQuantity']}`}</Table.Td>
</Table.Tr>
));
}, [histories]);
const handleCallApi = useCallback(async () => {
if (!data?.lot_id) {
setHistories([]);
return;
}
const response = await getDetailBidHistories(data?.lot_id);
if (response.data && response.data) {
setHistories(response.data);
}
}, [data]);
useEffect(() => {
handleCallApi();
}, [handleCallApi]);
return (
<Modal {...props} size="xl" title={<span className="text-xl font-bold">BIDDING HISTORY</span>} centered>
<Table striped highlightOnHover withTableBorder withColumnBorders>
<Table.Thead>
<Table.Tr>
<Table.Th>Bidding Details</Table.Th>
<Table.Th>Bid Time</Table.Th>
<Table.Th>Bid Price</Table.Th>
<Table.Th>Bid Qty</Table.Th>
<Table.Th>Win Qty</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{histories.length <= 0 ? (
<Table.Tr>
<Table.Td colSpan={5} className="text-center">
None
</Table.Td>
</Table.Tr>
) : (
rows
)}
</Table.Tbody>
</Table>
</Modal>
);
}

View File

@ -8,7 +8,7 @@ export interface IShowHistoriesModalModalProps extends ModalProps {
onUpdated?: () => void;
}
export default function ConfigModal({ data, onUpdated, ...props }: IShowHistoriesModalModalProps) {
export default function ShowHistoriesModal({ data, onUpdated, ...props }: IShowHistoriesModalModalProps) {
const rows = data?.histories.map((element) => (
<Table.Tr key={element.id}>
<Table.Td>{element.id}</Table.Td>

View File

@ -7,9 +7,9 @@ import { Box, Checkbox, MantineStyleProp, Table as MTable, TableProps as MTableP
import { AxiosError, AxiosResponse } from 'axios';
import DOMPurify from 'dompurify';
import React, { ChangeEvent, CSSProperties, ReactNode, useCallback, useEffect, useImperativeHandle, useState } from 'react';
import { v4 as uuid } from 'uuid';
import TableActions, { ITableActionsProps } from './action';
import Filter from './filter';
import { v4 as uuid } from 'uuid';
import { IChooseOptions, IColumn, IColumnStyle, IDataFilter, IFilterItemProps, IOptions, ITableFilter, ITableShort, TableChildProps, TKeyPagination, TRefTableFn } from './type';
import { defaultPathToData, defaultPrefixShort, defaultStyleHightlight, flowShort, getParamsData as getParamsFromURL, searchKey } from './ultils';
@ -639,6 +639,7 @@ const Table = <R extends Record<string, any>>({
)}
</div>
<MTable.ScrollContainer minWidth={500} type="scrollarea">
<MTable {...props}>
<MTable.Thead {...thead}>
<MTable.Tr {...trhead}>
@ -769,6 +770,7 @@ const Table = <R extends Record<string, any>>({
))}
</MTable.Tbody>
</MTable>
</MTable.ScrollContainer>
</>
);
};

View File

@ -77,3 +77,8 @@ export const removeFalsy = (data: { [key: string]: string | number }) => {
return prev;
}, {} as { [key: string]: string | number });
};
export function extractNumber(str: string) {
const match = str.match(/\d+(\.\d+)?/);
return match ? parseFloat(match[0]) : null;
}

View File

@ -4,7 +4,7 @@ import { IconAd, IconAdOff, IconEdit, IconHistory, IconMenu, IconTrash } from '@
import _ from 'lodash';
import { useMemo, useRef, useState } from 'react';
import { deleteBid, deletesBid, getBids, toggleBid } from '../apis/bid';
import { BidModal, ShowHistoriesModal } from '../components/bid';
import { BidModal, ShowHistoriesBidGraysApiModal, ShowHistoriesModal } from '../components/bid';
import Table from '../lib/table/table';
import { IColumn, TRefTableFn } from '../lib/table/type';
import { useConfirmStore } from '../lib/zustand/use-confirm';
@ -20,13 +20,14 @@ export default function Bids() {
const { setConfirm } = useConfirmStore();
const [openedHistories, historiesModel] = useDisclosure(false);
const [openedHistoriesGraysApi, historiesGraysApiModel] = useDisclosure(false);
const [openedBid, bidModal] = useDisclosure(false);
const columns: IColumn<IBid>[] = [
{
key: 'id',
title: 'ID',
typeFilter: 'number',
key: 'name',
title: 'Name',
typeFilter: 'text',
},
{
key: 'lot_id',
@ -38,14 +39,14 @@ export default function Bids() {
title: 'Model',
typeFilter: 'text',
},
// {
// key: 'quantity',
// title: 'Qty',
// typeFilter: 'number',
// },
{
key: 'quantity',
title: 'Qty',
typeFilter: 'number',
},
{
key: 'step_price',
title: 'Step price',
key: 'plus_price',
title: 'Plus price',
typeFilter: 'number',
},
{
@ -58,6 +59,11 @@ export default function Bids() {
title: 'Current price',
typeFilter: 'number',
},
{
key: 'reserve_price',
title: 'Reserve price',
typeFilter: 'number',
},
{
key: 'histories',
title: 'Current bid',
@ -107,14 +113,14 @@ export default function Bids() {
},
},
{
key: 'updated_at',
title: 'Update at',
typeFilter: 'none',
renderRow(row) {
return <span className="text-sm">{formatTime(row.updated_at)}</span>;
},
},
// {
// key: 'updated_at',
// title: 'Update at',
// typeFilter: 'none',
// renderRow(row) {
// return <span className="text-sm">{formatTime(row.updated_at)}</span>;
// },
// },
];
const handleDelete = (bid: IBid) => {
@ -242,6 +248,15 @@ export default function Bids() {
>
Histories
</Menu.Item>
<Menu.Item
onClick={() => {
setClickData(row);
historiesGraysApiModel.open();
}}
leftSection={<IconHistory size={14} />}
>
Bids
</Menu.Item>
<Menu.Item
disabled={row.status === 'win-bid'}
@ -293,6 +308,23 @@ export default function Bids() {
}}
data={clickData}
/>
<ShowHistoriesBidGraysApiModal
onUpdated={() => {
if (refTableFn.current?.fetchData) {
refTableFn.current.fetchData();
}
setClickData(null);
}}
opened={openedHistoriesGraysApi}
onClose={() => {
historiesGraysApiModel.close();
setClickData(null);
}}
data={clickData}
/>
</Box>
);
}

View File

@ -5,10 +5,9 @@ import io from 'socket.io-client';
import { WorkingPage } from '../components/dashboard';
import { IBid, IWebBid } from '../system/type';
// Dùng singleton để giữ kết nối WebSocket khi chuyển trang
const socket = io(import.meta.env.VITE_SOCKET_URL, {
autoConnect: true, // Tránh tự động kết nối khi import file
transports: ['websocket'], // Chỉ dùng WebSocket để giảm độ trễ
autoConnect: true,
transports: ['websocket'],
});
export default function DashBoard() {

View File

@ -16,13 +16,14 @@ export interface ITimestamp {
export interface IBid extends ITimestamp {
id: number;
max_price: number;
reserve_price: number;
current_price: number;
name: string | null;
quantity: number;
url: string;
model: string;
lot_id: string;
step_price: number;
plus_price: number;
close_time: string | null;
start_bid_time: string | null;
first_bid: boolean;

View File

@ -21,6 +21,7 @@
"axios": "^1.8.3",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"dayjs": "^1.11.13",
"multer": "^1.4.5-lts.1",
"mysql2": "^3.13.0",
"nestjs-paginate": "^11.1.0",

View File

@ -32,6 +32,7 @@
"axios": "^1.8.3",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"dayjs": "^1.11.13",
"multer": "^1.4.5-lts.1",
"mysql2": "^3.13.0",
"nestjs-paginate": "^11.1.0",

View File

@ -0,0 +1,74 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import axios from 'axios';
import { escapeMarkdownV2 } from 'src/ultils';
import { Bid } from '../entities/bid.entity';
import * as dayjs from 'dayjs';
@Injectable()
export class BotTelegramApi {
private readonly botToken: string;
private readonly apiUrl: string;
constructor(private readonly configService: ConfigService) {
this.botToken = this.configService.get<string>('TELEGRAM_BOT_TOKEN');
this.apiUrl = `https://api.telegram.org/bot${this.botToken}`;
}
formatBidMessage(bid: Bid) {
return `<b>📢 New Bid Alert!</b>\n
<b>📌 Product:</b> ${bid.name ? `<b>${bid.name}</b>` : '_No name_'}\n
<b>💰 Current Price:</b> <code>${bid.current_price.toLocaleString()}${bid.plus_price ? ` (+${bid.plus_price.toLocaleString()})` : ''}</code>\n
<b>🏆 Max Price:</b> <code>${bid.max_price.toLocaleString()}</code>\n
<b>🔖 Starting Price:</b> <code>${bid.reserve_price.toLocaleString()}</code>\n\n
<b>📦 Model:</b> <code>${bid.model || 'None'}</code>\n
<b>🔢 Quantity:</b> <code>${bid.quantity}</code>\n
<b>🆔 Lot ID:</b> <code>${bid.lot_id ? bid.lot_id.toString() : 'None'}</code>\n\n
<b> Closing Time:</b> ${bid.close_time ? `<code>${dayjs(bid.close_time).format('HH:mm:ss DD/MM/YYYY')}</code>` : '_Not available_'}\n
<b>🚀 Start Time:</b> ${bid.start_bid_time ? `<code>${dayjs(bid.start_bid_time).format('HH:mm:ss DD/MM/YYYY')}</code>` : '_Not available_'}\n\n
<b>📈 Bid History:</b>\n${
bid.histories.length > 0
? bid.histories
.map(
(h, index) =>
` ${index + 1}. 💵 <code>${index === 0 ? '🔴' : ''}${h.price.toLocaleString()}</code> 🕒 <code>${dayjs(h.updated_at).format('HH:mm:ss DD/MM/YYYY')}</code>`,
)
.join('\n')
: '_No bid history yet_'
}\n\n
<b>📝 Status:</b> <code>${bid.status.replace('-', ' ')}</code>\n\n
<b>View Details:</b> <a href="${bid.url}">Click here</a>`;
}
async sendMessage(
text: string,
bonusBody: Record<string, any> = {},
chatId: string = this.configService.get<string>('CHAT_ID'),
): Promise<void> {
try {
const url = `${this.apiUrl}/sendMessage`;
const response = await axios.post(
url,
{
chat_id: chatId,
text: text,
...bonusBody,
},
{
family: 4,
},
);
} catch (error) {
console.error('Error sending message:', error || error.message);
}
}
async sendBidInfo(bid: Bid): Promise<void> {
const text = this.formatBidMessage(bid);
console.log(text);
await this.sendMessage(text, {
parse_mode: 'HTML',
});
}
}

View File

@ -0,0 +1,21 @@
import { Injectable } from '@nestjs/common';
import axios from 'axios';
import AppResponse from 'src/response/app-response';
import { Bid } from '../entities/bid.entity';
@Injectable()
export class GraysApi {
async getHistoriesBid(lot_id: Bid['lot_id']) {
try {
const response = await axios({
url: `https://www.grays.com/api/LotInfo/GetBiddingHistory?lotId=${lot_id}&currencyCode=AUD`,
});
if (response.data && response.data?.Bids) {
return AppResponse.toResponse(response.data.Bids);
}
} catch (error) {
return AppResponse.toResponse([]);
}
}
}

View File

@ -17,6 +17,8 @@ import { BidHistoriesService } from './services/bid-histories.service';
import { BidsService } from './services/bids.service';
import { OutBidLogsService } from './services/out-bid-logs.service';
import { WebBidsService } from './services/web-bids.service';
import { BotTelegramApi } from './apis/bot-telegram.api';
import { GraysApi } from './apis/grays.api';
@Module({
imports: [
@ -40,6 +42,8 @@ import { WebBidsService } from './services/web-bids.service';
BidGateway,
OutBidLogsService,
WebBidsService,
BotTelegramApi,
GraysApi,
],
})
export class BidsModule {}

View File

@ -1,15 +1,25 @@
import { Body, Controller, Get, Post } from '@nestjs/common';
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
import { BidsService } from '../../services/bids.service';
import { CreateBidDto } from '../../dto/bid/create-bid.dto';
import { BidHistoriesService } from '../../services/bid-histories.service';
import { CreateBidHistoryDto } from '../../dto/bid-history/create-bid-history.dto';
import { Bid } from '../../entities/bid.entity';
import { GraysApi } from '../../apis/grays.api';
@Controller('admin/bid-histories')
export class AdminBidHistoriesController {
constructor(private readonly bidHistoriesService: BidHistoriesService) {}
constructor(
private readonly bidHistoriesService: BidHistoriesService,
private readonly graysApi: GraysApi,
) {}
@Post()
create(@Body() data: CreateBidHistoryDto) {
return this.bidHistoriesService.create(data);
}
@Get('detail/:lot_id')
async getBidHistories(@Param('lot_id') lot_id: Bid['lot_id']) {
return await this.graysApi.getHistoriesBid(lot_id);
}
}

View File

@ -7,7 +7,7 @@ export class BidHistoriesController {
constructor(private readonly bidHistoriesService: BidHistoriesService) {}
@Post()
create(@Body() data: CreateBidHistoryDto) {
return this.bidHistoriesService.create(data);
async create(@Body() data: CreateBidHistoryDto) {
return await this.bidHistoriesService.create(data);
}
}

View File

@ -15,4 +15,8 @@ export class ClientUpdateBidDto {
@IsNumber()
@IsOptional()
current_price: number;
@IsNumber()
@IsOptional()
reserve_price: number;
}

View File

@ -20,5 +20,5 @@ export class CreateBidDto {
@IsNumber()
@IsOptional()
step_price: number;
plus_price: number;
}

View File

@ -11,5 +11,5 @@ export class UpdateBidDto {
@IsNumber()
@IsOptional()
step_price: number;
plus_price: number;
}

View File

@ -30,7 +30,10 @@ export class Bid extends Timestamp {
lot_id: string;
@Column({ default: 0 })
step_price: number;
plus_price: number;
@Column({ default: 0 })
reserve_price: number;
@Column({ default: null, nullable: true })
name: string;

View File

@ -1,12 +1,13 @@
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';
import { Column, Entity, PrimaryGeneratedColumn, Unique } from 'typeorm';
import { Timestamp } from './timestamp';
@Entity('out-bid-logs')
@Unique(['model', 'out_price'])
export class OutBidLog extends Timestamp {
@PrimaryGeneratedColumn('increment')
id: number;
@Column({ unique: true, default: null, nullable: true })
@Column({ default: null, nullable: true })
model: string | null;
@Column({ default: null, nullable: true })

View File

@ -28,7 +28,11 @@ export class BidGateway implements OnGatewayConnection {
async onModuleInit() {
this.eventEmitter.on('bids.updated', (data) => {
this.server.emit('bidsUpdated', data); // Gửi sự kiện WebSocket đến client
this.server.emit('bidsUpdated', data);
});
this.eventEmitter.on('web.updated', (data) => {
this.server.emit('webUpdated', data);
});
this.eventEmitter.on('working', (data) => {

View File

@ -11,6 +11,7 @@ import { Repository } from 'typeorm';
import { BidHistory } from '../entities/bid-history.entity';
import { Bid } from '../entities/bid.entity';
import { CreateBidHistoryDto } from '../dto/bid-history/create-bid-history.dto';
import { BotTelegramApi } from '../apis/bot-telegram.api';
@Injectable()
export class BidHistoriesService {
@ -19,6 +20,7 @@ export class BidHistoriesService {
readonly bidHistoriesRepo: Repository<BidHistory>,
@InjectRepository(Bid)
readonly bidsRepo: Repository<Bid>,
private readonly botTelegramApi: BotTelegramApi,
) {}
async index() {
@ -47,7 +49,7 @@ export class BidHistoriesService {
);
}
if (price + bid.step_price > bid.max_price) {
if (price + bid.plus_price > bid.max_price) {
this.bidsRepo.update(bid_id, { status: 'out-bid' });
throw new BadRequestException(
@ -70,6 +72,8 @@ export class BidHistoriesService {
this.bidsRepo.update(bid_id, { first_bid: false });
}
this.botTelegramApi.sendBidInfo({ ...bid, histories: response });
return AppResponse.toResponse(plainToClass(BidHistory, response));
}
}

View File

@ -127,10 +127,10 @@ export class BidsService {
const result = await this.bidsRepo.update(id, {
...data,
status:
prev.max_price + prev.step_price > data.max_price
? 'out-bid'
: prev.status,
// status:
// prev.max_price + prev.plus_price > data.max_price
// ? 'out-bid'
// : prev.status,
});
if (!result) throw new BadRequestException(false);
@ -167,7 +167,7 @@ export class BidsService {
},
});
if (lastHistory && lastHistory.price + bid.step_price >= bid.max_price) {
if (lastHistory && lastHistory.price + bid.plus_price >= bid.max_price) {
throw new BadRequestException(
AppResponse.toResponse(false, { message: 'Price is out of Max Price' }),
);
@ -175,7 +175,7 @@ export class BidsService {
if (bid.close_time && isTimeReached(bid.close_time)) {
throw new BadRequestException(
AppResponse.toResponse(false, { message: 'Close was closed' }),
AppResponse.toResponse(false, { message: 'Product was closed' }),
);
}
@ -201,7 +201,7 @@ export class BidsService {
bid.start_bid_time = subtractMinutes(data.close_time, 5);
}
if (data.current_price > bid.max_price + bid.step_price) {
if (data.current_price > bid.max_price + bid.plus_price) {
bid.status = 'out-bid';
}

View File

@ -47,13 +47,17 @@ export class OutBidLogsService {
}
async create(data: CreateOutBidLogDto[]) {
try {
const result = await this.outbidLogRepo.upsert(data, {
conflictPaths: ['model', 'id'],
conflictPaths: ['model', 'lot_id'],
skipUpdateIfNoValuesChanged: true,
});
if (!result) throw new BadRequestException(false);
return AppResponse.toResponse(true);
} catch (error) {
throw new BadRequestException(false);
}
}
}

View File

@ -76,6 +76,12 @@ export class WebBidsService {
this.eventEmitter.emit('bids.updated', data);
}
async emitAccountUpdate(id: WebBid['id']) {
const data = await this.webBidRepo.findOne({ where: { id } });
this.eventEmitter.emit('web.updated', data || null);
}
async createByUrl(url: string) {
const originUrl = extractDomain(url);
@ -131,6 +137,9 @@ export class WebBidsService {
if (!result) throw new BadRequestException(false);
if (data.password !== prev.password || data.username !== prev.username) {
this.emitAccountUpdate(prev.id);
}
this.emitAllBidEvent();
return AppResponse.toResponse(true);

View File

@ -16,7 +16,8 @@ import { TypeOrmModule } from '@nestjs/typeorm';
database: configService.get<string>('DB_NAME'),
charset: 'utf8mb4_unicode_ci',
entities: ['dist/**/*.entity{.ts,.js}'],
synchronize: true,
synchronize:
configService.get<string>('ENVIRONMENT') === 'prod' ? false : true,
}),
}),
],

View File

@ -24,3 +24,7 @@ export function extractDomain(url: string): string | null {
return null;
}
}
export function escapeMarkdownV2(text: string) {
return text.replace(/[_*[\]()~`>#+\-=|{}.!\\]/g, '\\$&');
}

View File

@ -1,10 +1,10 @@
import 'dotenv/config';
import _ from 'lodash';
import { io } from 'socket.io-client';
import { GrayApiBid } from './models/grays.com/grays-api-bid.js';
import { GraysProductBid } from './models/grays.com/grays-product-bid.js';
import { createApiBid, createBidProduct, deleteProfile } from './service/app-service.js';
import browser from './system/browser.js';
import configs from './system/config.js';
import { isTimeReached, safeClosePage } from './system/utils.js';
import browser from './system/browser.js';
let MANAGER_BIDS = [];
@ -12,31 +12,7 @@ let _INTERVAL_TRACKING_ID = null;
let _CLEAR_LAZY_TAB_ID = null;
let _WORK_TRACKING_ID = null;
const handleCloseRemoveProduct = (data) => {
if (!Array.isArray(data)) return;
data.forEach(async (item) => {
if (item.page_context) {
safeClosePage(item);
}
});
};
const createBidProduct = (web, data) => {
switch (web.origin_url) {
case configs.WEB_URLS.GRAYS: {
return new GraysProductBid({ ...data });
}
}
};
const createApiBid = (web) => {
switch (web.origin_url) {
case configs.WEB_URLS.GRAYS: {
return new GrayApiBid({ ...web });
}
}
};
global.IS_CLEANING = false;
const handleUpdateProductTabs = (data) => {
if (!Array.isArray(data)) {
@ -75,6 +51,7 @@ const handleUpdateProductTabs = (data) => {
const tracking = async () => {
if (_INTERVAL_TRACKING_ID) {
clearInterval(_INTERVAL_TRACKING_ID);
_INTERVAL_TRACKING_ID = null;
}
_INTERVAL_TRACKING_ID = setInterval(async () => {
@ -121,42 +98,129 @@ const tracking = async () => {
}, configs.AUTO_TRACKING_DELAY);
};
// const clearLazyTab = async () => {
// if (_CLEAR_LAZY_TAB_ID) {
// clearInterval(_CLEAR_LAZY_TAB_ID);
// _CLEAR_LAZY_TAB_ID = null; // Reset tránh memory leak
// }
// try {
// _CLEAR_LAZY_TAB_ID = setInterval(async () => {
// if (!browser) {
// console.warn('⚠️ Browser is not available.');
// return;
// }
// try {
// const pages = await browser.pages();
// // Lấy danh sách URL từ flattenedArray
// const activeUrls = _.flatMap(MANAGER_BIDS, (item) => [
// // item.url,
// ...item.children.map((child) => child.url),
// ]).filter(Boolean); // Lọc bỏ null hoặc undefined
// console.log(
// '🔍 Page URLs:',
// pages.map((page) => page.url()),
// );
// for (const page of pages) {
// const pageUrl = page.url();
// if (!activeUrls.includes(pageUrl)) {
// if (!page.isClosed()) {
// try {
// await page.close();
// console.log(`🛑 Closing unused tab: ${pageUrl}`);
// } catch (err) {
// console.warn(`⚠️ Error closing tab ${pageUrl}:`, err.message);
// }
// }
// }
// }
// } catch (err) {
// console.error('❌ Error in clearLazyTab:', err.message);
// }
// }, configs.AUTO_TRACKING_CLEANING);
// } catch (error) {
// console.log('CLEAR LAZY TAB: ', error.message);
// }
// };
const clearLazyTab = async () => {
if (_CLEAR_LAZY_TAB_ID) {
clearInterval(_CLEAR_LAZY_TAB_ID);
_CLEAR_LAZY_TAB_ID = null;
}
try {
_CLEAR_LAZY_TAB_ID = setInterval(async () => {
if (!global.IS_CLEANING) return;
if (!browser) {
console.warn('⚠️ Browser is not available or disconnected.');
clearInterval(_CLEAR_LAZY_TAB_ID);
_CLEAR_LAZY_TAB_ID = null;
return;
}
try {
const pages = await browser.pages();
// Lấy danh sách URL từ flattenedArray
const activeUrls = _.flatMap(MANAGER_BIDS, (item) => [item.url, ...item.children.map((child) => child.url)]).filter(Boolean); // Lọc bỏ null hoặc undefined
console.log({ activeUrls });
console.log(
'🔍 Page URLs:',
pages.map((page) => page.url()),
);
for (const page of pages) {
const pageUrl = page.url();
// 🔥 Bỏ qua tab 'about:blank' hoặc tab không có URL
if (!pageUrl || pageUrl === 'about:blank') continue;
if (!activeUrls.includes(pageUrl)) {
console.log(`Closing unused tab: ${pageUrl}`);
if (!page.isClosed() && browser.isConnected()) {
try {
await page.close();
console.log(`🛑 Closing unused tab: ${pageUrl}`);
} catch (err) {
console.warn(`⚠️ Error closing tab ${pageUrl}:`, err.message);
}
}
}
}
} catch (err) {
console.error('❌ Error in clearLazyTab:', err.message);
}
}, configs.AUTO_TRACKING_CLEANING);
} catch (error) {
console.log('CLEAR LAZY TAB ERROR: ', error.message);
}
};
const workTracking = () => {
try {
if (_WORK_TRACKING_ID) {
clearInterval(_WORK_TRACKING_ID);
_WORK_TRACKING_ID = null;
}
_WORK_TRACKING_ID = setInterval(() => {
const activeData = _.flatMap(MANAGER_BIDS, (item) => [item, ...item.children]);
for (const item of activeData) {
if (item.page_context && !item.page_context.isClosed()) {
item.handleTakeWorkSnapshot();
}
}
}, 10000);
} catch (error) {
console.log('Loi oi day');
}
};
(async () => {
@ -180,6 +244,26 @@ const workTracking = () => {
await Promise.all(MANAGER_BIDS.map((apiBid) => apiBid.listen_events()));
});
socket.on('webUpdated', async (data) => {
console.log('📢 Account was updated:', data);
const isDeleted = deleteProfile(data);
if (isDeleted) {
console.log('✅ Profile deleted successfully!');
const tabs = MANAGER_BIDS.filter((item) => item.url === data.url || item?.web_bid.url === data.url);
if (tabs.length <= 0) return;
await Promise.all(tabs.map((tab) => safeClosePage(tab)));
await Promise.all(MANAGER_BIDS.map((apiBid) => apiBid.listen_events()));
} else {
console.log('⚠️ No profile found to delete.');
}
});
// AUTO TRACKING
tracking();

View File

@ -1,11 +1,10 @@
import * as fs from 'fs';
import path from 'path';
import BID_TYPE from '../system/bid-type.js';
import browser from '../system/browser.js';
import CONSTANTS from '../system/constants.js';
import { sanitizeFileName } from '../system/utils.js';
import { Account } from './account.js';
import { getPathProfile, sanitizeFileName } from '../system/utils.js';
import { Bid } from './bid.js';
import * as fs from 'fs';
import path from 'path';
export class ApiBid extends Bid {
id;
@ -93,7 +92,7 @@ export class ApiBid extends Bid {
async restoreContext() {
if (!this.browser_context || !this.page_context) return;
const filePath = path.join(CONSTANTS.PROFILE_PATH, sanitizeFileName(this.origin_url) + '.json');
const filePath = getPathProfile(this.origin_url);
if (!fs.existsSync(filePath)) return;

View File

@ -1,9 +1,9 @@
import path from 'path';
import { createOutBidLog } from '../../system/apis/out-bid-log.js';
import configs from '../../system/config.js';
import CONSTANTS from '../../system/constants.js';
import { delay, extractNumber, isTimeReached, safeClosePage, takeSnapshot } from '../../system/utils.js';
import { delay, extractNumber, getPathProfile, isTimeReached, safeClosePage } from '../../system/utils.js';
import { ApiBid } from '../api-bid.js';
import * as fs from 'fs';
import fs from 'fs';
export class GrayApiBid extends ApiBid {
retry_login = 0;
@ -129,20 +129,25 @@ export class GrayApiBid extends ApiBid {
async handleLogin() {
const page = this.page_context;
const filePath = getPathProfile(this.origin_url);
// 🔍 Check if already logged in (login input should not be visible)
if (!(await page.$('input[name="username"]'))) {
if (!(await page.$('input[name="username"]')) || fs.existsSync(filePath)) {
console.log('✅ Already logged in, skipping login.');
global.IS_CLEANING = true;
this.retry_login = 0; // Reset retry count
return;
}
console.log('🔑 Starting login process...');
global.IS_CLEANING = false;
try {
await page.type('input[name="username"]', this.username, { delay: 100 });
await page.type('input[name="password"]', this.password, { delay: 150 });
await page.click('#loginButton');
try {
await Promise.race([
page.waitForNavigation({ timeout: 8000, waitUntil: 'domcontentloaded' }),
page.waitForFunction(() => !document.querySelector('input[name="username"]'), { timeout: 8000 }), // Check if login input disappears
@ -151,6 +156,7 @@ export class GrayApiBid extends ApiBid {
if (!(await page.$('input[name="username"]'))) {
console.log('✅ Login successful!');
this.retry_login = 0; // Reset retry count after success
global.IS_CLEANING = true;
return;
}

View File

@ -22,7 +22,7 @@ export class GraysProductBid extends ProductBid {
return { result: false, bid_price: 0 };
}
const bid_price = this.step_price + Number(price_value);
const bid_price = this.plus_price + Number(price_value);
if (bid_price > this.max_price) {
console.log('PRICE BID is more than MAX_VALUE => STOP BID THIS PRODUCT ❌');
@ -141,10 +141,10 @@ export class GraysProductBid extends ProductBid {
await delay(1000);
}
async handleUpdateBid({ lot_id, close_time, name, current_price }) {
async handleUpdateBid({ lot_id, close_time, name, current_price, reserve_price }) {
if (close_time && this.close_time == close_time) return;
const response = await updateBid(this.id, { lot_id, close_time, name, current_price });
const response = await updateBid(this.id, { lot_id, close_time, name, current_price, reserve_price: Number(reserve_price) || 0 });
if (response) {
this.lot_id = response.lot_id;
@ -181,9 +181,9 @@ export class GraysProductBid extends ProductBid {
const current_price =
(await page.$eval('#biddableLot > form > div > div:nth-child(1) > div:nth-child(1) > div:nth-child(2) > div > span > span', (el) => el.innerText)) || null;
console.log(`📌 Product Info: Lot ID: ${lot_id}, Name: ${name}, Current Price: ${current_price}`);
console.log(`📌 Product Info: Lot ID: ${lot_id}, Name: ${name}, Current Price: ${current_price}, Reserve price ${price_value}`);
this.handleUpdateBid({ lot_id, close_time, name, current_price: current_price ? extractNumber(current_price) : null });
this.handleUpdateBid({ lot_id, reserve_price: price_value, close_time, name, current_price: current_price ? extractNumber(current_price) : null });
const { result, bid_price } = await this.validate({ page, price_value });

View File

@ -3,7 +3,7 @@ import path from 'path';
import BID_TYPE from '../system/bid-type.js';
import browser from '../system/browser.js';
import CONSTANTS from '../system/constants.js';
import { delay, sanitizeFileName } from '../system/utils.js';
import { delay, getPathProfile, sanitizeFileName } from '../system/utils.js';
import { Bid } from './bid.js';
export class ProductBid extends Bid {
@ -11,7 +11,7 @@ export class ProductBid extends Bid {
max_price;
model;
lot_id;
step_price;
plus_price;
close_time;
first_bid;
quantity;
@ -23,11 +23,12 @@ export class ProductBid extends Bid {
web_bid;
current_price;
name;
reserve_price;
constructor({
url,
max_price,
step_price,
plus_price,
model,
first_bid = false,
id,
@ -40,12 +41,13 @@ export class ProductBid extends Bid {
start_bid_time,
web_bid,
current_price,
reserve_price,
name,
}) {
super(BID_TYPE.PRODUCT_TAB, url);
this.max_price = max_price || 0;
this.model = model;
this.step_price = step_price || 0;
this.plus_price = plus_price || 0;
this.first_bid = first_bid;
this.id = id;
this.created_at = created_at;
@ -58,12 +60,13 @@ export class ProductBid extends Bid {
this.web_bid = web_bid;
this.current_price = current_price;
this.name = name;
this.reserve_price = reserve_price;
}
setNewData({
url,
max_price,
step_price,
plus_price,
model,
first_bid = false,
id,
@ -76,11 +79,12 @@ export class ProductBid extends Bid {
start_bid_time,
web_bid,
current_price,
reserve_price,
name,
}) {
this.max_price = max_price || 0;
this.model = model;
this.step_price = step_price || 0;
this.plus_price = plus_price || 0;
this.first_bid = first_bid;
this.id = id;
this.created_at = created_at;
@ -94,6 +98,7 @@ export class ProductBid extends Bid {
this.url = url;
this.current_price = current_price;
this.name = name;
this.reserve_price = reserve_price;
}
puppeteer_connect = async () => {
@ -107,7 +112,7 @@ export class ProductBid extends Bid {
const statusInit = await this.restoreContext(context);
if (!statusInit) {
console.log(`⚠️ Restore failed. Restart count`);
console.log(`⚠️ Restore failed.`);
return;
}
@ -117,7 +122,7 @@ export class ProductBid extends Bid {
};
async restoreContext(context) {
const filePath = path.join(CONSTANTS.PROFILE_PATH, sanitizeFileName(this.web_bid.origin_url) + '.json');
const filePath = getPathProfile(this.web_bid.origin_url);
if (!fs.existsSync(filePath)) return false;

View File

@ -10,6 +10,7 @@
"license": "ISC",
"dependencies": {
"axios": "^1.8.2",
"dotenv": "^16.4.7",
"lodash": "^4.17.21",
"puppeteer": "^24.4.0",
"puppeteer-extra": "^3.3.6",
@ -480,6 +481,18 @@
"integrity": "sha512-yRtvFD8Oyk7C9Os3GmnFZLu53yAfsnyw1s+mLmHHUK0GQEc9zthHWvS1r67Zqzm5t7v56PILHIVZ7kmFMaL2yQ==",
"license": "BSD-3-Clause"
},
"node_modules/dotenv": {
"version": "16.4.7",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz",
"integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",

View File

@ -13,6 +13,7 @@
"description": "",
"dependencies": {
"axios": "^1.8.2",
"dotenv": "^16.4.7",
"lodash": "^4.17.21",
"puppeteer": "^24.4.0",
"puppeteer-extra": "^3.3.6",

View File

@ -0,0 +1,43 @@
import path from 'path';
import { GrayApiBid } from '../models/grays.com/grays-api-bid.js';
import { GraysProductBid } from '../models/grays.com/grays-product-bid.js';
import configs from '../system/config.js';
import CONSTANTS from '../system/constants.js';
import { sanitizeFileName } from '../system/utils.js';
import * as fs from 'fs';
export const handleCloseRemoveProduct = (data) => {
if (!Array.isArray(data)) return;
data.forEach(async (item) => {
if (item.page_context) {
safeClosePage(item);
}
});
};
export const createBidProduct = (web, data) => {
switch (web.origin_url) {
case configs.WEB_URLS.GRAYS: {
return new GraysProductBid({ ...data });
}
}
};
export const createApiBid = (web) => {
switch (web.origin_url) {
case configs.WEB_URLS.GRAYS: {
return new GrayApiBid({ ...web });
}
}
};
export const deleteProfile = (data) => {
const filePath = path.join(CONSTANTS.PROFILE_PATH, sanitizeFileName(data.origin_url) + '.json');
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
return true;
}
return false;
};

View File

@ -28,13 +28,13 @@ export const updateBid = async (id, values) => {
});
if (!data || !data?.data) {
console.log('❌ UPDATE FAILURE');
console.log('❌ UPDATE FAILURE (UPDATE BID)');
return null;
}
return data.data;
} catch (error) {
console.log('❌ ERROR IN SERVER: ', error);
console.log('❌ ERROR IN SERVER: (UPDATE BID) ', error.response);
return null;
}
};

View File

@ -1,7 +1,8 @@
import ax from 'axios';
const axios = ax.create({
baseURL: 'http://localhost:4000/api/v1/',
// baseURL: 'http://172.18.2.125/api/v1/',
baseURL: process.env.BASE_URL,
});
export default axios;

View File

@ -5,7 +5,7 @@ import StealthPlugin from 'puppeteer-extra-plugin-stealth';
puppeteer.use(StealthPlugin());
const browser = await puppeteer.launch({
headless: false, // Để false để quan sát quá trình login
headless: process.env.ENVIRONMENT === 'prod' ? true : false,
// userDataDir: CONSTANTS.PROFILE_PATH, // Thư mục lưu profile
args: [
'--no-sandbox',

View File

@ -1,13 +1,13 @@
const configs = {
AUTO_TRACKING_DELAY: 5000,
AUTO_TRACKING_CLEANING: 10000,
SOCKET_URL: 'http://localhost:4000',
SOCKET_URL: process.env.SOCKET_URL,
WEB_URLS: {
GRAYS: `https://www.grays.com`,
},
WEB_CONFIGS: {
GRAYS: {
AUTO_CALL_API_TO_TRACKING: 10000,
AUTO_CALL_API_TO_TRACKING: 3000,
API_CALL_TO_TRACKING: 'https://www.grays.com/api/Notifications/GetOutBidLots',
},
},

View File

@ -23,7 +23,7 @@ export const takeSnapshot = async (page, item, imageName, type = CONSTANTS.TYPE_
console.log(`📂 Save at folder: ${typeDir}`);
}
await page.waitForSelector('body', { visible: true });
await page.waitForSelector('body', { visible: true, timeout: 5000 });
// Chụp ảnh màn hình và lưu vào filePath
await page.screenshot({ path: filePath, fullPage: true });
@ -42,6 +42,7 @@ export const takeSnapshot = async (page, item, imageName, type = CONSTANTS.TYPE_
export const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
export async function safeClosePage(item) {
try {
const page = item.page_context;
if (!page?.isClosed() && page?.close) {
@ -49,6 +50,12 @@ export async function safeClosePage(item) {
}
item.page_context = undefined;
if (item?.page_context) {
item.page_context = undefined;
}
} catch (error) {
console.log("Can't close item: " + item.id);
}
}
export function isTimeReached(targetTime) {
@ -68,3 +75,7 @@ export function extractNumber(str) {
export const sanitizeFileName = (url) => {
return url.replace(/[:\/]/g, '_');
};
export const getPathProfile = (origin_url) => {
return path.join(CONSTANTS.PROFILE_PATH, sanitizeFileName(origin_url) + '.json');
};