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>) => { 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 { try {
const { data } = await axios({ const { data } = await axios({
url: 'bids/' + bid.id, url: 'bids/' + bid.id,
withCredentials: true, withCredentials: true,
method: 'PUT', method: 'PUT',
data: { step_price, max_price, quantity }, data: { plus_price, max_price, quantity },
}); });
handleSuccess(data); handleSuccess(data);

View File

@ -15,7 +15,7 @@ export interface IBidModelProps extends ModalProps {
const schema = { const schema = {
url: z.string({ message: 'Url is required' }).url('Invalid url format'), 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'), 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(), 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 { } 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; if (!result) return;
@ -92,9 +92,10 @@ export default function BidModal({ data, onUpdated, ...props }: IBidModelProps)
centered centered
> >
<form onSubmit={form.onSubmit(handleSubmit)} className="grid grid-cols-2 gap-2.5"> <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')} /> <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 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')} /> <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"> <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 ShowHistoriesModal } from './show-histories-modal';
export { default as ShowHistoriesBidGraysApiModal } from './show-histories-bid-grays-api-modal';
export { default as BidModal } from './bid-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; onUpdated?: () => void;
} }
export default function ConfigModal({ data, onUpdated, ...props }: IShowHistoriesModalModalProps) { export default function ShowHistoriesModal({ data, onUpdated, ...props }: IShowHistoriesModalModalProps) {
const rows = data?.histories.map((element) => ( const rows = data?.histories.map((element) => (
<Table.Tr key={element.id}> <Table.Tr key={element.id}>
<Table.Td>{element.id}</Table.Td> <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 { AxiosError, AxiosResponse } from 'axios';
import DOMPurify from 'dompurify'; import DOMPurify from 'dompurify';
import React, { ChangeEvent, CSSProperties, ReactNode, useCallback, useEffect, useImperativeHandle, useState } from 'react'; import React, { ChangeEvent, CSSProperties, ReactNode, useCallback, useEffect, useImperativeHandle, useState } from 'react';
import { v4 as uuid } from 'uuid';
import TableActions, { ITableActionsProps } from './action'; import TableActions, { ITableActionsProps } from './action';
import Filter from './filter'; 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 { IChooseOptions, IColumn, IColumnStyle, IDataFilter, IFilterItemProps, IOptions, ITableFilter, ITableShort, TableChildProps, TKeyPagination, TRefTableFn } from './type';
import { defaultPathToData, defaultPrefixShort, defaultStyleHightlight, flowShort, getParamsData as getParamsFromURL, searchKey } from './ultils'; import { defaultPathToData, defaultPrefixShort, defaultStyleHightlight, flowShort, getParamsData as getParamsFromURL, searchKey } from './ultils';
@ -639,6 +639,7 @@ const Table = <R extends Record<string, any>>({
)} )}
</div> </div>
<MTable.ScrollContainer minWidth={500} type="scrollarea">
<MTable {...props}> <MTable {...props}>
<MTable.Thead {...thead}> <MTable.Thead {...thead}>
<MTable.Tr {...trhead}> <MTable.Tr {...trhead}>
@ -769,6 +770,7 @@ const Table = <R extends Record<string, any>>({
))} ))}
</MTable.Tbody> </MTable.Tbody>
</MTable> </MTable>
</MTable.ScrollContainer>
</> </>
); );
}; };

View File

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

View File

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

View File

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

View File

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

View File

@ -32,6 +32,7 @@
"axios": "^1.8.3", "axios": "^1.8.3",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.1", "class-validator": "^0.14.1",
"dayjs": "^1.11.13",
"multer": "^1.4.5-lts.1", "multer": "^1.4.5-lts.1",
"mysql2": "^3.13.0", "mysql2": "^3.13.0",
"nestjs-paginate": "^11.1.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 { BidsService } from './services/bids.service';
import { OutBidLogsService } from './services/out-bid-logs.service'; import { OutBidLogsService } from './services/out-bid-logs.service';
import { WebBidsService } from './services/web-bids.service'; import { WebBidsService } from './services/web-bids.service';
import { BotTelegramApi } from './apis/bot-telegram.api';
import { GraysApi } from './apis/grays.api';
@Module({ @Module({
imports: [ imports: [
@ -40,6 +42,8 @@ import { WebBidsService } from './services/web-bids.service';
BidGateway, BidGateway,
OutBidLogsService, OutBidLogsService,
WebBidsService, WebBidsService,
BotTelegramApi,
GraysApi,
], ],
}) })
export class BidsModule {} 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 { BidsService } from '../../services/bids.service';
import { CreateBidDto } from '../../dto/bid/create-bid.dto'; import { CreateBidDto } from '../../dto/bid/create-bid.dto';
import { BidHistoriesService } from '../../services/bid-histories.service'; import { BidHistoriesService } from '../../services/bid-histories.service';
import { CreateBidHistoryDto } from '../../dto/bid-history/create-bid-history.dto'; 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') @Controller('admin/bid-histories')
export class AdminBidHistoriesController { export class AdminBidHistoriesController {
constructor(private readonly bidHistoriesService: BidHistoriesService) {} constructor(
private readonly bidHistoriesService: BidHistoriesService,
private readonly graysApi: GraysApi,
) {}
@Post() @Post()
create(@Body() data: CreateBidHistoryDto) { create(@Body() data: CreateBidHistoryDto) {
return this.bidHistoriesService.create(data); 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) {} constructor(private readonly bidHistoriesService: BidHistoriesService) {}
@Post() @Post()
create(@Body() data: CreateBidHistoryDto) { async create(@Body() data: CreateBidHistoryDto) {
return this.bidHistoriesService.create(data); return await this.bidHistoriesService.create(data);
} }
} }

View File

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

View File

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

View File

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

View File

@ -30,7 +30,10 @@ export class Bid extends Timestamp {
lot_id: string; lot_id: string;
@Column({ default: 0 }) @Column({ default: 0 })
step_price: number; plus_price: number;
@Column({ default: 0 })
reserve_price: number;
@Column({ default: null, nullable: true }) @Column({ default: null, nullable: true })
name: string; 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'; import { Timestamp } from './timestamp';
@Entity('out-bid-logs') @Entity('out-bid-logs')
@Unique(['model', 'out_price'])
export class OutBidLog extends Timestamp { export class OutBidLog extends Timestamp {
@PrimaryGeneratedColumn('increment') @PrimaryGeneratedColumn('increment')
id: number; id: number;
@Column({ unique: true, default: null, nullable: true }) @Column({ default: null, nullable: true })
model: string | null; model: string | null;
@Column({ default: null, nullable: true }) @Column({ default: null, nullable: true })

View File

@ -28,7 +28,11 @@ export class BidGateway implements OnGatewayConnection {
async onModuleInit() { async onModuleInit() {
this.eventEmitter.on('bids.updated', (data) => { 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) => { this.eventEmitter.on('working', (data) => {

View File

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

View File

@ -127,10 +127,10 @@ export class BidsService {
const result = await this.bidsRepo.update(id, { const result = await this.bidsRepo.update(id, {
...data, ...data,
status: // status:
prev.max_price + prev.step_price > data.max_price // prev.max_price + prev.plus_price > data.max_price
? 'out-bid' // ? 'out-bid'
: prev.status, // : prev.status,
}); });
if (!result) throw new BadRequestException(false); 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( throw new BadRequestException(
AppResponse.toResponse(false, { message: 'Price is out of Max Price' }), 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)) { if (bid.close_time && isTimeReached(bid.close_time)) {
throw new BadRequestException( 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); 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'; bid.status = 'out-bid';
} }

View File

@ -47,13 +47,17 @@ export class OutBidLogsService {
} }
async create(data: CreateOutBidLogDto[]) { async create(data: CreateOutBidLogDto[]) {
try {
const result = await this.outbidLogRepo.upsert(data, { const result = await this.outbidLogRepo.upsert(data, {
conflictPaths: ['model', 'id'], conflictPaths: ['model', 'lot_id'],
skipUpdateIfNoValuesChanged: true, skipUpdateIfNoValuesChanged: true,
}); });
if (!result) throw new BadRequestException(false); if (!result) throw new BadRequestException(false);
return AppResponse.toResponse(true); 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); 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) { async createByUrl(url: string) {
const originUrl = extractDomain(url); const originUrl = extractDomain(url);
@ -131,6 +137,9 @@ export class WebBidsService {
if (!result) throw new BadRequestException(false); if (!result) throw new BadRequestException(false);
if (data.password !== prev.password || data.username !== prev.username) {
this.emitAccountUpdate(prev.id);
}
this.emitAllBidEvent(); this.emitAllBidEvent();
return AppResponse.toResponse(true); return AppResponse.toResponse(true);

View File

@ -16,7 +16,8 @@ import { TypeOrmModule } from '@nestjs/typeorm';
database: configService.get<string>('DB_NAME'), database: configService.get<string>('DB_NAME'),
charset: 'utf8mb4_unicode_ci', charset: 'utf8mb4_unicode_ci',
entities: ['dist/**/*.entity{.ts,.js}'], 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; return null;
} }
} }
export function escapeMarkdownV2(text: string) {
return text.replace(/[_*[\]()~`>#+\-=|{}.!\\]/g, '\\$&');
}

View File

@ -1,10 +1,10 @@
import 'dotenv/config';
import _ from 'lodash'; import _ from 'lodash';
import { io } from 'socket.io-client'; import { io } from 'socket.io-client';
import { GrayApiBid } from './models/grays.com/grays-api-bid.js'; import { createApiBid, createBidProduct, deleteProfile } from './service/app-service.js';
import { GraysProductBid } from './models/grays.com/grays-product-bid.js'; import browser from './system/browser.js';
import configs from './system/config.js'; import configs from './system/config.js';
import { isTimeReached, safeClosePage } from './system/utils.js'; import { isTimeReached, safeClosePage } from './system/utils.js';
import browser from './system/browser.js';
let MANAGER_BIDS = []; let MANAGER_BIDS = [];
@ -12,31 +12,7 @@ let _INTERVAL_TRACKING_ID = null;
let _CLEAR_LAZY_TAB_ID = null; let _CLEAR_LAZY_TAB_ID = null;
let _WORK_TRACKING_ID = null; let _WORK_TRACKING_ID = null;
const handleCloseRemoveProduct = (data) => { global.IS_CLEANING = false;
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 });
}
}
};
const handleUpdateProductTabs = (data) => { const handleUpdateProductTabs = (data) => {
if (!Array.isArray(data)) { if (!Array.isArray(data)) {
@ -75,6 +51,7 @@ const handleUpdateProductTabs = (data) => {
const tracking = async () => { const tracking = async () => {
if (_INTERVAL_TRACKING_ID) { if (_INTERVAL_TRACKING_ID) {
clearInterval(_INTERVAL_TRACKING_ID); clearInterval(_INTERVAL_TRACKING_ID);
_INTERVAL_TRACKING_ID = null;
} }
_INTERVAL_TRACKING_ID = setInterval(async () => { _INTERVAL_TRACKING_ID = setInterval(async () => {
@ -121,42 +98,129 @@ const tracking = async () => {
}, configs.AUTO_TRACKING_DELAY); }, 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 () => { const clearLazyTab = async () => {
if (_CLEAR_LAZY_TAB_ID) { if (_CLEAR_LAZY_TAB_ID) {
clearInterval(_CLEAR_LAZY_TAB_ID); clearInterval(_CLEAR_LAZY_TAB_ID);
_CLEAR_LAZY_TAB_ID = null;
} }
try {
_CLEAR_LAZY_TAB_ID = setInterval(async () => { _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(); const pages = await browser.pages();
// Lấy danh sách URL từ flattenedArray // 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 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) { for (const page of pages) {
const pageUrl = page.url(); 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)) { if (!activeUrls.includes(pageUrl)) {
console.log(`Closing unused tab: ${pageUrl}`); if (!page.isClosed() && browser.isConnected()) {
try {
await page.close(); 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); }, configs.AUTO_TRACKING_CLEANING);
} catch (error) {
console.log('CLEAR LAZY TAB ERROR: ', error.message);
}
}; };
const workTracking = () => { const workTracking = () => {
try {
if (_WORK_TRACKING_ID) { if (_WORK_TRACKING_ID) {
clearInterval(_WORK_TRACKING_ID); clearInterval(_WORK_TRACKING_ID);
_WORK_TRACKING_ID = null;
} }
_WORK_TRACKING_ID = setInterval(() => { _WORK_TRACKING_ID = setInterval(() => {
const activeData = _.flatMap(MANAGER_BIDS, (item) => [item, ...item.children]); const activeData = _.flatMap(MANAGER_BIDS, (item) => [item, ...item.children]);
for (const item of activeData) { for (const item of activeData) {
if (item.page_context && !item.page_context.isClosed()) {
item.handleTakeWorkSnapshot(); item.handleTakeWorkSnapshot();
} }
}
}, 10000); }, 10000);
} catch (error) {
console.log('Loi oi day');
}
}; };
(async () => { (async () => {
@ -180,6 +244,26 @@ const workTracking = () => {
await Promise.all(MANAGER_BIDS.map((apiBid) => apiBid.listen_events())); 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 // AUTO TRACKING
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 BID_TYPE from '../system/bid-type.js';
import browser from '../system/browser.js'; import browser from '../system/browser.js';
import CONSTANTS from '../system/constants.js'; import CONSTANTS from '../system/constants.js';
import { sanitizeFileName } from '../system/utils.js'; import { getPathProfile, sanitizeFileName } from '../system/utils.js';
import { Account } from './account.js';
import { Bid } from './bid.js'; import { Bid } from './bid.js';
import * as fs from 'fs';
import path from 'path';
export class ApiBid extends Bid { export class ApiBid extends Bid {
id; id;
@ -93,7 +92,7 @@ export class ApiBid extends Bid {
async restoreContext() { async restoreContext() {
if (!this.browser_context || !this.page_context) return; 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; 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 { createOutBidLog } from '../../system/apis/out-bid-log.js';
import configs from '../../system/config.js'; import configs from '../../system/config.js';
import CONSTANTS from '../../system/constants.js'; import { delay, extractNumber, getPathProfile, isTimeReached, safeClosePage } from '../../system/utils.js';
import { delay, extractNumber, isTimeReached, safeClosePage, takeSnapshot } from '../../system/utils.js';
import { ApiBid } from '../api-bid.js'; import { ApiBid } from '../api-bid.js';
import * as fs from 'fs'; import fs from 'fs';
export class GrayApiBid extends ApiBid { export class GrayApiBid extends ApiBid {
retry_login = 0; retry_login = 0;
@ -129,20 +129,25 @@ export class GrayApiBid extends ApiBid {
async handleLogin() { async handleLogin() {
const page = this.page_context; const page = this.page_context;
const filePath = getPathProfile(this.origin_url);
// 🔍 Check if already logged in (login input should not be visible) // 🔍 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.'); console.log('✅ Already logged in, skipping login.');
global.IS_CLEANING = true;
this.retry_login = 0; // Reset retry count this.retry_login = 0; // Reset retry count
return; return;
} }
console.log('🔑 Starting login process...'); 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="username"]', this.username, { delay: 100 });
await page.type('input[name="password"]', this.password, { delay: 150 }); await page.type('input[name="password"]', this.password, { delay: 150 });
await page.click('#loginButton'); await page.click('#loginButton');
try {
await Promise.race([ await Promise.race([
page.waitForNavigation({ timeout: 8000, waitUntil: 'domcontentloaded' }), page.waitForNavigation({ timeout: 8000, waitUntil: 'domcontentloaded' }),
page.waitForFunction(() => !document.querySelector('input[name="username"]'), { timeout: 8000 }), // Check if login input disappears 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"]'))) { if (!(await page.$('input[name="username"]'))) {
console.log('✅ Login successful!'); console.log('✅ Login successful!');
this.retry_login = 0; // Reset retry count after success this.retry_login = 0; // Reset retry count after success
global.IS_CLEANING = true;
return; return;
} }

View File

@ -22,7 +22,7 @@ export class GraysProductBid extends ProductBid {
return { result: false, bid_price: 0 }; 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) { if (bid_price > this.max_price) {
console.log('PRICE BID is more than MAX_VALUE => STOP BID THIS PRODUCT ❌'); 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); 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; 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) { if (response) {
this.lot_id = response.lot_id; this.lot_id = response.lot_id;
@ -181,9 +181,9 @@ export class GraysProductBid extends ProductBid {
const current_price = 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; (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 }); 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 BID_TYPE from '../system/bid-type.js';
import browser from '../system/browser.js'; import browser from '../system/browser.js';
import CONSTANTS from '../system/constants.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'; import { Bid } from './bid.js';
export class ProductBid extends Bid { export class ProductBid extends Bid {
@ -11,7 +11,7 @@ export class ProductBid extends Bid {
max_price; max_price;
model; model;
lot_id; lot_id;
step_price; plus_price;
close_time; close_time;
first_bid; first_bid;
quantity; quantity;
@ -23,11 +23,12 @@ export class ProductBid extends Bid {
web_bid; web_bid;
current_price; current_price;
name; name;
reserve_price;
constructor({ constructor({
url, url,
max_price, max_price,
step_price, plus_price,
model, model,
first_bid = false, first_bid = false,
id, id,
@ -40,12 +41,13 @@ export class ProductBid extends Bid {
start_bid_time, start_bid_time,
web_bid, web_bid,
current_price, current_price,
reserve_price,
name, name,
}) { }) {
super(BID_TYPE.PRODUCT_TAB, url); super(BID_TYPE.PRODUCT_TAB, url);
this.max_price = max_price || 0; this.max_price = max_price || 0;
this.model = model; this.model = model;
this.step_price = step_price || 0; this.plus_price = plus_price || 0;
this.first_bid = first_bid; this.first_bid = first_bid;
this.id = id; this.id = id;
this.created_at = created_at; this.created_at = created_at;
@ -58,12 +60,13 @@ export class ProductBid extends Bid {
this.web_bid = web_bid; this.web_bid = web_bid;
this.current_price = current_price; this.current_price = current_price;
this.name = name; this.name = name;
this.reserve_price = reserve_price;
} }
setNewData({ setNewData({
url, url,
max_price, max_price,
step_price, plus_price,
model, model,
first_bid = false, first_bid = false,
id, id,
@ -76,11 +79,12 @@ export class ProductBid extends Bid {
start_bid_time, start_bid_time,
web_bid, web_bid,
current_price, current_price,
reserve_price,
name, name,
}) { }) {
this.max_price = max_price || 0; this.max_price = max_price || 0;
this.model = model; this.model = model;
this.step_price = step_price || 0; this.plus_price = plus_price || 0;
this.first_bid = first_bid; this.first_bid = first_bid;
this.id = id; this.id = id;
this.created_at = created_at; this.created_at = created_at;
@ -94,6 +98,7 @@ export class ProductBid extends Bid {
this.url = url; this.url = url;
this.current_price = current_price; this.current_price = current_price;
this.name = name; this.name = name;
this.reserve_price = reserve_price;
} }
puppeteer_connect = async () => { puppeteer_connect = async () => {
@ -107,7 +112,7 @@ export class ProductBid extends Bid {
const statusInit = await this.restoreContext(context); const statusInit = await this.restoreContext(context);
if (!statusInit) { if (!statusInit) {
console.log(`⚠️ Restore failed. Restart count`); console.log(`⚠️ Restore failed.`);
return; return;
} }
@ -117,7 +122,7 @@ export class ProductBid extends Bid {
}; };
async restoreContext(context) { 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; if (!fs.existsSync(filePath)) return false;

View File

@ -10,6 +10,7 @@
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"axios": "^1.8.2", "axios": "^1.8.2",
"dotenv": "^16.4.7",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"puppeteer": "^24.4.0", "puppeteer": "^24.4.0",
"puppeteer-extra": "^3.3.6", "puppeteer-extra": "^3.3.6",
@ -480,6 +481,18 @@
"integrity": "sha512-yRtvFD8Oyk7C9Os3GmnFZLu53yAfsnyw1s+mLmHHUK0GQEc9zthHWvS1r67Zqzm5t7v56PILHIVZ7kmFMaL2yQ==", "integrity": "sha512-yRtvFD8Oyk7C9Os3GmnFZLu53yAfsnyw1s+mLmHHUK0GQEc9zthHWvS1r67Zqzm5t7v56PILHIVZ7kmFMaL2yQ==",
"license": "BSD-3-Clause" "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": { "node_modules/dunder-proto": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",

View File

@ -13,6 +13,7 @@
"description": "", "description": "",
"dependencies": { "dependencies": {
"axios": "^1.8.2", "axios": "^1.8.2",
"dotenv": "^16.4.7",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"puppeteer": "^24.4.0", "puppeteer": "^24.4.0",
"puppeteer-extra": "^3.3.6", "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) { if (!data || !data?.data) {
console.log('❌ UPDATE FAILURE'); console.log('❌ UPDATE FAILURE (UPDATE BID)');
return null; return null;
} }
return data.data; return data.data;
} catch (error) { } catch (error) {
console.log('❌ ERROR IN SERVER: ', error); console.log('❌ ERROR IN SERVER: (UPDATE BID) ', error.response);
return null; return null;
} }
}; };

View File

@ -1,7 +1,8 @@
import ax from 'axios'; import ax from 'axios';
const axios = ax.create({ 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; export default axios;

View File

@ -5,7 +5,7 @@ import StealthPlugin from 'puppeteer-extra-plugin-stealth';
puppeteer.use(StealthPlugin()); puppeteer.use(StealthPlugin());
const browser = await puppeteer.launch({ 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 // userDataDir: CONSTANTS.PROFILE_PATH, // Thư mục lưu profile
args: [ args: [
'--no-sandbox', '--no-sandbox',

View File

@ -1,13 +1,13 @@
const configs = { const configs = {
AUTO_TRACKING_DELAY: 5000, AUTO_TRACKING_DELAY: 5000,
AUTO_TRACKING_CLEANING: 10000, AUTO_TRACKING_CLEANING: 10000,
SOCKET_URL: 'http://localhost:4000', SOCKET_URL: process.env.SOCKET_URL,
WEB_URLS: { WEB_URLS: {
GRAYS: `https://www.grays.com`, GRAYS: `https://www.grays.com`,
}, },
WEB_CONFIGS: { WEB_CONFIGS: {
GRAYS: { GRAYS: {
AUTO_CALL_API_TO_TRACKING: 10000, AUTO_CALL_API_TO_TRACKING: 3000,
API_CALL_TO_TRACKING: 'https://www.grays.com/api/Notifications/GetOutBidLots', 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}`); 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 // Chụp ảnh màn hình và lưu vào filePath
await page.screenshot({ path: filePath, fullPage: true }); 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 const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
export async function safeClosePage(item) { export async function safeClosePage(item) {
try {
const page = item.page_context; const page = item.page_context;
if (!page?.isClosed() && page?.close) { if (!page?.isClosed() && page?.close) {
@ -49,6 +50,12 @@ export async function safeClosePage(item) {
} }
item.page_context = undefined; 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) { export function isTimeReached(targetTime) {
@ -68,3 +75,7 @@ export function extractNumber(str) {
export const sanitizeFileName = (url) => { export const sanitizeFileName = (url) => {
return url.replace(/[:\/]/g, '_'); return url.replace(/[:\/]/g, '_');
}; };
export const getPathProfile = (origin_url) => {
return path.join(CONSTANTS.PROFILE_PATH, sanitizeFileName(origin_url) + '.json');
};