This commit is contained in:
Admin 2025-04-28 16:30:24 +07:00
parent 173841c57c
commit b13712a317
45 changed files with 4190 additions and 2513 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

View File

@ -38,6 +38,7 @@
"tailwind-merge": "^3.0.1", "tailwind-merge": "^3.0.1",
"tailwindcss": "^4.0.6", "tailwindcss": "^4.0.6",
"uuid": "^11.0.5", "uuid": "^11.0.5",
"yet-another-react-lightbox": "^3.22.0",
"zod": "^3.24.1", "zod": "^3.24.1",
"zustand": "^5.0.3" "zustand": "^5.0.3"
}, },
@ -1422,7 +1423,7 @@
"version": "19.0.3", "version": "19.0.3",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.0.3.tgz", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.0.3.tgz",
"integrity": "sha512-0Knk+HJiMP/qOZgMyNFamlIjw9OFCsyC2ZbigmEEyXXixgre6IQpm/4V+r3qH4GC1JPvRJKInw+on2rV6YZLeA==", "integrity": "sha512-0Knk+HJiMP/qOZgMyNFamlIjw9OFCsyC2ZbigmEEyXXixgre6IQpm/4V+r3qH4GC1JPvRJKInw+on2rV6YZLeA==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peerDependencies": { "peerDependencies": {
"@types/react": "^19.0.0" "@types/react": "^19.0.0"
@ -7541,6 +7542,29 @@
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/yet-another-react-lightbox": {
"version": "3.22.0",
"resolved": "https://registry.npmjs.org/yet-another-react-lightbox/-/yet-another-react-lightbox-3.22.0.tgz",
"integrity": "sha512-yaXmzUraH/Ftsp7eG/E2leQgXhtrG8c1t+jImlSjC2XtZ7XkvjIV2vP/1kl5kxmsBHjck/98W/9Xxempry+2QQ==",
"license": "MIT",
"engines": {
"node": ">=14"
},
"peerDependencies": {
"@types/react": "^16 || ^17 || ^18 || ^19",
"@types/react-dom": "^16 || ^17 || ^18 || ^19",
"react": "^16.8.0 || ^17 || ^18 || ^19",
"react-dom": "^16.8.0 || ^17 || ^18 || ^19"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/zod": { "node_modules/zod": {
"version": "3.24.1", "version": "3.24.1",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.24.1.tgz", "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.1.tgz",

View File

@ -40,6 +40,7 @@
"tailwind-merge": "^3.0.1", "tailwind-merge": "^3.0.1",
"tailwindcss": "^4.0.6", "tailwindcss": "^4.0.6",
"uuid": "^11.0.5", "uuid": "^11.0.5",
"yet-another-react-lightbox": "^3.22.0",
"zod": "^3.24.1", "zod": "^3.24.1",
"zustand": "^5.0.3" "zustand": "^5.0.3"
}, },

View File

@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { generateNestParams, handleError, handleSuccess } from '.'; import { generateNestParams, handleError, handleSuccess } from '.';
import axios from '../lib/axios'; import axios from '../lib/axios';
import { IAdmin } from '../system/type'; import { IAdmin } from '../system/type';
@ -51,7 +52,8 @@ export const grantNewPasswordAdmin = async (admin: Partial<IAdmin>) => {
}; };
export const createAdmin = async (admin: Omit<IAdmin, 'id' | 'created_at' | 'updated_at' | 'is_system_account'>) => { export const createAdmin = async (admin: Omit<IAdmin, 'id' | 'created_at' | 'updated_at' | 'is_system_account'>) => {
const newData = removeFalsyValues(admin); const {permissions , ...newData} = removeFalsyValues(admin);
try { try {
const { data } = await axios({ const { data } = await axios({

View File

@ -34,3 +34,18 @@ export const shutdownTool = async () => {
handleError(error); handleError(error);
} }
}; };
export const getStatusTool = async () => {
try {
const { data } = await axios({
url: `${BASE_URL}/status-tool`,
withCredentials: true,
method: 'GET',
});
return data;
} catch (error) {
handleError(error);
}
};

View File

@ -9,7 +9,7 @@ export const handleError = (error: unknown) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const response = (error as AxiosError).response as Record<string, any>; const response = (error as AxiosError).response as Record<string, any>;
const data = response.data; const data = response?.data;
if (response.status === HttpStatusCode.Forbidden) return; if (response.status === HttpStatusCode.Forbidden) return;

View File

@ -127,14 +127,14 @@ export default function AdminModal({ data, onUpdated, ...props }: IAdminModelPro
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">
<TextInput readOnly={!!data} size="sm" label="Username" {...form.getInputProps('username')} /> <TextInput withAsterisk readOnly={!!data} size="sm" label="Username" {...form.getInputProps('username')} />
<TextInput size="sm" label="Email" {...form.getInputProps('email')} /> <TextInput withAsterisk size="sm" label="Email" {...form.getInputProps('email')} />
<TextInput className="col-span-2" size="sm" label="Fullname" {...form.getInputProps('fullname')} /> <TextInput withAsterisk className="col-span-2" size="sm" label="Fullname" {...form.getInputProps('fullname')} />
{!data && ( {!data && (
<> <>
<PasswordInput size="sm" label="Password" {...form.getInputProps('password')} /> <PasswordInput withAsterisk size="sm" label="Password" {...form.getInputProps('password')} />
<PasswordInput size="sm" label="Confirm password" {...form.getInputProps('confirmPassword')} /> <PasswordInput withAsterisk size="sm" label="Confirm password" {...form.getInputProps('confirmPassword')} />
</> </>
)} )}

View File

@ -87,8 +87,8 @@ export default function GrantNewPasswordModal({ data, onUpdated, ...props }: IAd
centered centered
> >
<form onSubmit={form.onSubmit(handleSubmit)} className="flex flex-col gap-2.5"> <form onSubmit={form.onSubmit(handleSubmit)} className="flex flex-col gap-2.5">
<PasswordInput className="col-span-1" size="sm" label="Password" {...form.getInputProps('password')} /> <PasswordInput withAsterisk className="col-span-1" size="sm" label="Password" {...form.getInputProps('password')} />
<PasswordInput className="col-span-1" size="sm" label="Confirm password" {...form.getInputProps('confirmPassword')} /> <PasswordInput withAsterisk className="col-span-1" size="sm" label="Confirm password" {...form.getInputProps('confirmPassword')} />
<Button className="col-span-2" type="submit" fullWidth size="sm" mt="md"> <Button className="col-span-2" type="submit" fullWidth size="sm" mt="md">
{'Grant'} {'Grant'}

View File

@ -1,3 +1,4 @@
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 ShowHistoriesBidGraysApiModal } from './show-histories-bid-grays-api-modal';
export { default as ShowHistoriesBidPicklesApiModal } from './show-histories-bid-pickles-api-modal';
export { default as BidModal } from './bid-modal'; export { default as BidModal } from './bid-modal';

View File

@ -17,8 +17,8 @@ export default function ShowHistoriesBidGraysApiModal({ data, onUpdated, ...prop
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const rows = useMemo(() => { const rows = useMemo(() => {
return histories.map((element) => ( return histories.map((element, index) => (
<Table.Tr key={element.LotId}> <Table.Tr key={index}>
<Table.Td>{`${element['UserInitials']} - ${element['UserShortAddress']}`}</Table.Td> <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>{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>{`AU $${element['Price']}`}</Table.Td>

View File

@ -0,0 +1,74 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { LoadingOverlay, Modal, ModalProps, Table } from '@mantine/core';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { getDetailBidHistories } from '../../apis/bid-histories';
import { IBid } from '../../system/type';
import { formatTime } from '../../utils';
export interface IShowHistoriesBidGraysApiModalProps extends ModalProps {
data: IBid | null;
onUpdated?: () => void;
}
export default function ShowHistoriesBidPicklesApiModal({ data, onUpdated, ...props }: IShowHistoriesBidGraysApiModalProps) {
const [histories, setHistories] = useState<Record<string, string>[]>([]);
const [loading, setLoading] = useState(false);
const rows = useMemo(() => {
return histories.map((element, index) => (
<Table.Tr key={index}>
<Table.Td>{element['bidderAnonName']}</Table.Td>
<Table.Td>{element['actualBid']}</Table.Td>
<Table.Td>{formatTime(new Date(element['bidTimeInMilliSeconds']).toUTCString())}</Table.Td>
</Table.Tr>
));
}, [histories]);
const handleCallApi = useCallback(async () => {
if (!data?.lot_id) {
setHistories([]);
return;
}
setLoading(true);
const response = await getDetailBidHistories(data?.lot_id);
setLoading(false);
if (response.data) {
setHistories(response.data);
}
}, [data]);
useEffect(() => {
handleCallApi();
}, [handleCallApi]);
return (
<Modal className="relative" {...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>Bidder name</Table.Th>
<Table.Th>Actual bid</Table.Th>
<Table.Th>Time</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>
<LoadingOverlay visible={loading} zIndex={1000} overlayProps={{ blur: 2 }} />
</Modal>
);
}

View File

@ -1,19 +1,14 @@
/* eslint-disable @typescript-eslint/no-unused-vars */ /* eslint-disable @typescript-eslint/no-unused-vars */
import { Image, Modal, ModalProps, ScrollArea } from '@mantine/core'; import { ModalProps } from '@mantine/core';
import Lightbox from "yet-another-react-lightbox";
export default function ShowImageModal({ src, fallbackSrc, ...props }: ModalProps & { src: string; fallbackSrc: string }) { import "yet-another-react-lightbox/plugins/captions.css";
import Fullscreen from "yet-another-react-lightbox/plugins/fullscreen";
import "yet-another-react-lightbox/plugins/thumbnails.css";
import Zoom from "yet-another-react-lightbox/plugins/zoom";
import "yet-another-react-lightbox/styles.css";
export default function ShowImageModal({ src, fallbackSrc,opened, onClose, ...props }: ModalProps & { src: string; fallbackSrc: string }) {
return ( return (
<Modal <Lightbox {...props} open={opened} close={onClose} slides={[{ src: src || fallbackSrc }]}
classNames={{ plugins={[Fullscreen, Zoom]}/>
header: '!flex !item-center !justify-center w-full',
}}
{...props}
size={'xl'}
title={<span className="text-xl font-bold">Image</span>}
centered
scrollAreaComponent={ScrollArea.Autosize}
>
<Image src={src} fallbackSrc={fallbackSrc} />
</Modal>
); );
} }

View File

@ -1,104 +1,196 @@
import { Box, Button, Image, Text } from '@mantine/core'; import { Badge, Box, Button, Image, Text } from "@mantine/core";
import { useDisclosure } from '@mantine/hooks'; import { useDisclosure } from "@mantine/hooks";
import moment from 'moment'; import moment from "moment";
import { useEffect, useState } from 'react'; import { useEffect, useState } from "react";
import { Socket } from 'socket.io-client'; import { Socket } from "socket.io-client";
import { getImagesWorking } from '../../apis/bid'; import { getImagesWorking } from "../../apis/bid";
import { IBid, IWebBid } from '../../system/type'; import { useStatusToolStore } from "../../lib/zustand/use-status-tool-store";
import ShowImageModal from './show-image-modal'; import { IBid, IWebBid } from "../../system/type";
import { cn, stringToColor } from "../../utils";
import ShowImageModal from "./show-image-modal";
export interface IWorkingPageProps { export interface IWorkingPageProps {
data: (IBid | IWebBid) & { type: string }; data: (IBid | IWebBid) & { type: string };
socket: Socket; socket: Socket;
} }
export default function WorkingPage({ data, socket }: IWorkingPageProps) { export default function WorkingPage({ data, socket }: IWorkingPageProps) {
const fallbackSrc = 'https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcRGh5WFH8TOIfRKxUrIgJZoDCs1yvQ4hIcppw&s'; const fallbackSrc =
"https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcRGh5WFH8TOIfRKxUrIgJZoDCs1yvQ4hIcppw&s";
const [opened, { open, close }] = useDisclosure(false); const [opened, { open, close }] = useDisclosure(false);
const [imageSrc, setImageSrc] = useState<string | null>(null); const [imageSrc, setImageSrc] = useState<string | null>(null);
const [lastUpdate, setLastUpdate] = useState<Date | null>(null); const [lastUpdate, setLastUpdate] = useState<Date | null>(null);
function isIBid(obj: IBid | IWebBid): obj is IBid { const [payloadLoginStatus, setPayloadLoginStatus] = useState<{
return 'name' in obj; data: IWebBid;
login_status: boolean;
} | null>(null);
const { statusTool } = useStatusToolStore();
function isIBid(obj: IBid | IWebBid): obj is IBid {
return "name" in obj;
}
const renderUrl = (
{ type, id }: (IBid | IWebBid) & { type: string },
name: string
) => {
return `${import.meta.env.VITE_BASE_URL}bids/status-working/${type
.replace("_", "-")
.toLowerCase()}/${id}/${name}`;
};
const extractTime = (filename: string) => {
return Number(filename.split("-")[0]) || 0;
};
const statusLabel = () => {
if (
statusTool &&
statusTool === "online" &&
payloadLoginStatus?.login_status
) {
return "logined";
} }
return !statusTool || statusTool !== "online" ? "Unknown" : "logout";
};
const renderUrl = ({ type, id }: (IBid | IWebBid) & { type: string }, name: string) => { useEffect(() => {
return `${import.meta.env.VITE_BASE_URL}bids/status-working/${type.replace('_', '-').toLowerCase()}/${id}/${name}`; const updateImage = ({
type,
id,
filename,
}: {
type: string;
filename: string;
id: IBid["id"];
}) => {
if (type == data.type && id == data.id) {
setLastUpdate(new Date(extractTime(filename)));
setImageSrc(renderUrl(data, filename));
}
}; };
const extractTime = (filename: string) => { socket.on("working", updateImage);
return Number(filename.split('-')[0]) || 0;
return () => {
socket.off("working", updateImage);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [socket, data.id, data.type]);
useEffect(() => {
const onLoginStatus = (data: { data: IWebBid; login_status: boolean }) => {
setPayloadLoginStatus(data);
console.log(
"%csrc/components/dashboard/working-page.tsx:60 data",
"color: #007acc;",
data
);
}; };
useEffect(() => { const origin_url = isIBid(data) ? data.web_bid.origin_url : data.origin_url;
const updateImage = ({ type, id, filename }: { type: string; filename: string; id: IBid['id'] }) => {
if (type == data.type && id == data.id) {
setLastUpdate(new Date(extractTime(filename)));
setImageSrc(renderUrl(data, filename));
}
};
socket.on('working', updateImage); socket.on(`login-status.${origin_url}`, onLoginStatus);
return () => { return () => {
socket.off('working', updateImage); socket.off(`login-status.${origin_url}`, onLoginStatus);
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps }, [data, socket]);
}, [socket, data.id, data.type]);
useEffect(() => { useEffect(() => {
(async () => { (async () => {
const result = await getImagesWorking(data); const result = await getImagesWorking(data);
if (!result || !result.data) return; if (!result || !result.data) return;
const filename = result.data[0]; const filename = result.data[0];
setImageSrc(renderUrl(data, filename)); setImageSrc(renderUrl(data, filename));
setLastUpdate(new Date(extractTime(filename))); setLastUpdate(new Date(extractTime(filename)));
})(); })();
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
return ( return (
<> <>
<Box className="rounded-md overflow-hidden relative shadow-lg"> <Box
<Image className={cn("rounded-md overflow-hidden relative shadow-lg", {
radius="md" ["border border-green-800"]: payloadLoginStatus?.login_status,
h={300} ["border border-red-800"]: !payloadLoginStatus?.login_status,
style={{ })}
objectFit: 'cover', >
}} <Image
fallbackSrc={fallbackSrc} radius="md"
src={imageSrc} h={300}
/> style={{
objectFit: "cover",
}}
fallbackSrc={fallbackSrc}
src={imageSrc}
/>
<Box className="absolute bg-black/60 inset-0 flex items-center justify-center text-white font-bold flex-col gap-3 p-4 rounded-lg transition duration-300 hover:bg-opacity-70"> <Box className="absolute bg-black/60 inset-0 flex items-center justify-center text-white font-bold flex-col gap-3 p-4 rounded-lg transition duration-300 hover:bg-opacity-70">
<Text className="text-lg tracking-wide text-center font-bold">{isIBid(data) ? data.name : 'Tracking page'}</Text> <Text className="text-lg tracking-wide text-center font-bold">
{isIBid(data) && <Text className="text-xs tracking-wide">{`Max price: $${data.max_price}`}</Text>} {isIBid(data) ? data.name : "Tracking page"}
{isIBid(data) && <Text className="text-xs tracking-wide">{`Current price: $${data.current_price}`}</Text>} </Text>
<Text className="text-sm italic opacity-80">{moment(lastUpdate).format('HH:mm:ss DD/MM/YYYY')}</Text> {isIBid(data) && (
<Box className="flex items-center gap-4"> <Text className="text-xs tracking-wide">{`Max price: $${data.max_price}`}</Text>
<Button size="xs" color="green" onClick={open} className="bg-white text-black px-4 py-2 rounded-md shadow-md hover:bg-gray-200 transition"> )}
Show {isIBid(data) && (
</Button> <Text className="text-xs tracking-wide">{`Current price: $${data.current_price}`}</Text>
<Button )}
target="_blank" <Text className="text-sm italic opacity-80">
component="a" {moment(lastUpdate).format("HH:mm:ss DD/MM/YYYY")}
size="xs" </Text>
href={data.url || '/'} <Box className="flex items-center gap-4">
className="bg-white text-black px-4 py-2 rounded-md shadow-md hover:bg-gray-200 transition" <Button
> size="xs"
Link color="green"
</Button> onClick={open}
</Box> className="bg-white text-black px-4 py-2 rounded-md shadow-md hover:bg-gray-200 transition"
</Box> >
</Box> Show
</Button>
<Button
target="_blank"
component="a"
size="xs"
href={data.url || "/"}
className="bg-white text-black px-4 py-2 rounded-md shadow-md hover:bg-gray-200 transition"
>
Link
</Button>
</Box>
</Box>
<ShowImageModal src={imageSrc || fallbackSrc} fallbackSrc={fallbackSrc} opened={opened} onClose={close} /> <Box className="absolute top-2.5 left-2.5 flex items-center gap-2">
</> <Badge
); color={payloadLoginStatus?.login_status ? "green" : "red"}
size="xs"
>
{statusLabel()}
</Badge>
<Badge
color={stringToColor(isIBid(data) ? data.web_bid.origin_url : data.origin_url)}
size="xs"
>
{isIBid(data) ? data.web_bid.origin_url : data.origin_url}
</Badge>
</Box>
</Box>
<ShowImageModal
src={imageSrc || fallbackSrc}
fallbackSrc={fallbackSrc}
opened={opened}
onClose={close}
/>
</>
);
} }

View File

@ -104,8 +104,8 @@ export default function WebBidModal({ data, onUpdated, ...props }: IWebBidModelP
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">
<TextInput className="col-span-2" size="sm" label="Domain" {...form.getInputProps('origin_url')} /> <TextInput withAsterisk className="col-span-2" size="sm" label="Domain" {...form.getInputProps('origin_url')} />
<TextInput className="col-span-2" size="sm" label="Tracking url" {...form.getInputProps('url')} /> <TextInput withAsterisk className="col-span-2" size="sm" label="Tracking url" {...form.getInputProps('url')} />
<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">
{data ? 'Update' : 'Create'} {data ? 'Update' : 'Create'}

View File

@ -0,0 +1,8 @@
const constants = {
grays: 'https://www.grays.com',
pickles:'https://www.pickles.com.au'
}
export const haveHistories = [constants.grays, constants.pickles]
export default constants

View File

@ -0,0 +1,13 @@
import { create } from "zustand";
type TStatusToolState = {
statusTool: string | boolean;
setStatusTool: (value: TStatusToolState["statusTool"]) => void;
};
export const useStatusToolStore = create<TStatusToolState>((set) => ({
statusTool: false,
props: {},
setStatusTool: (value: TStatusToolState["statusTool"]) =>
set({ statusTool: value }),
}));

View File

@ -1,340 +1,410 @@
import { ActionIcon, Badge, Box, Menu, Text, Tooltip } from '@mantine/core'; import { ActionIcon, Badge, Box, Menu, Text, Tooltip } from "@mantine/core";
import { useDisclosure } from '@mantine/hooks'; import { useDisclosure } from "@mantine/hooks";
import { IconAd, IconAdOff, IconEdit, IconHammer, IconHistory, IconMenu, IconTrash } from '@tabler/icons-react'; import {
import _ from 'lodash'; IconAd,
import { useMemo, useRef, useState } from 'react'; IconAdOff,
import { deleteBid, deletesBid, getBids, toggleBid } from '../apis/bid'; IconEdit,
import { BidModal, ShowHistoriesBidGraysApiModal, ShowHistoriesModal } from '../components/bid'; IconHammer,
import Table from '../lib/table/table'; IconHistory,
import { IColumn, TRefTableFn } from '../lib/table/type'; IconMenu,
import { useConfirmStore } from '../lib/zustand/use-confirm'; IconTrash,
import { mappingStatusColors } from '../system/constants'; } from "@tabler/icons-react";
import { IBid } from '../system/type'; import _ from "lodash";
import { formatTime } from '../utils'; import { useMemo, useRef, useState } from "react";
import { deleteBid, deletesBid, getBids, toggleBid } from "../apis/bid";
import {
BidModal,
ShowHistoriesBidGraysApiModal,
ShowHistoriesBidPicklesApiModal,
ShowHistoriesModal,
} from "../components/bid";
import Table from "../lib/table/table";
import { IColumn, TRefTableFn } from "../lib/table/type";
import { useConfirmStore } from "../lib/zustand/use-confirm";
import { mappingStatusColors } from "../system/constants";
import { IBid } from "../system/type";
import { formatTime } from "../utils";
import constants, { haveHistories } from "../constant";
export default function Bids() { export default function Bids() {
const refTableFn: TRefTableFn<IBid> = useRef({}); const refTableFn: TRefTableFn<IBid> = useRef({});
const [clickData, setClickData] = useState<IBid | null>(null); const [clickData, setClickData] = useState<IBid | null>(null);
const { setConfirm } = useConfirmStore(); const { setConfirm } = useConfirmStore();
const [openedHistories, historiesModel] = useDisclosure(false); const [openedHistories, historiesModel] = useDisclosure(false);
const [openedHistoriesGraysApi, historiesGraysApiModel] = useDisclosure(false); const [openedHistoriesGraysApi, historiesGraysApiModel] =
const [openedBid, bidModal] = useDisclosure(false); useDisclosure(false);
const columns: IColumn<IBid>[] = [ const [openedHistoriesPicklesApi, historiesPicklesApiModel] =
{ useDisclosure(false);
key: 'id', const [openedBid, bidModal] = useDisclosure(false);
title: 'ID',
typeFilter: 'number',
},
{
key: 'name',
title: 'Name',
typeFilter: 'text',
},
{
key: 'web_bid',
title: 'Web',
typeFilter: 'text',
renderRow(row) {
return <span>{row.web_bid.origin_url}</span>;
},
},
{
key: 'lot_id',
title: 'Lot ID',
typeFilter: 'text',
},
{
key: 'model',
title: 'Model',
typeFilter: 'text',
},
{ const columns: IColumn<IBid>[] = [
key: 'plus_price', {
title: 'Plus price', key: "id",
typeFilter: 'number', title: "ID",
}, typeFilter: "number",
{ },
key: 'max_price', {
title: 'Max price', key: "name",
typeFilter: 'number', title: "Name",
}, typeFilter: "text",
{ },
key: 'current_price', {
title: 'Current price', key: "web_bid",
typeFilter: 'number', title: "Web",
}, typeFilter: "text",
{ renderRow(row) {
key: 'reserve_price', return <span>{row.web_bid.origin_url}</span>;
title: 'Reserve price', },
typeFilter: 'number', },
}, {
{ key: "lot_id",
key: 'histories', title: "Lot ID",
title: 'Current bid', typeFilter: "text",
typeFilter: 'none', },
renderRow(row) { {
const bidPrice = _.maxBy(row.histories, 'price'); key: "model",
title: "Model",
typeFilter: "text",
},
return <Text>{bidPrice ? bidPrice.price : 'None'}</Text>; {
}, key: "plus_price",
}, title: "Plus price",
{ typeFilter: "number",
key: 'start_bid_time', },
title: 'Start bid', {
typeFilter: 'text', key: "max_price",
renderRow(row) { title: "Max price",
return ( typeFilter: "number",
<Tooltip hidden={!row.start_bid_time} label={row.start_bid_time}> },
<Text size="sm">{row.start_bid_time ? formatTime(row.start_bid_time, 'HH:mm:ss DD/MM/YYYY') : 'None'}</Text> {
</Tooltip> key: "current_price",
); title: "Current price",
}, typeFilter: "number",
}, },
{ {
key: 'close_time', key: "reserve_price",
title: 'Close time', title: "Reserve price",
typeFilter: 'text', typeFilter: "number",
renderRow(row) { },
return ( {
<Tooltip hidden={!row.close_time} label={row.close_time}> key: "histories",
<Text size="sm">{row.close_time ? formatTime(row.close_time, 'HH:mm:ss DD/MM/YYYY') : 'None'}</Text> title: "Current bid",
</Tooltip> typeFilter: "none",
); renderRow(row) {
}, const bidPrice = _.maxBy(row.histories, "price");
},
{
key: 'status',
title: 'Status',
typeFilter: 'text',
renderRow(row) {
return (
<Box className="flex items-center justify-center">
<Badge color={mappingStatusColors[row.status]} size="sm">
{row.status}
</Badge>
</Box>
);
},
},
];
const handleDelete = (bid: IBid) => { return <Text>{bidPrice ? bidPrice.price : "None"}</Text>;
setConfirm({ },
title: 'Delete ?', },
message: 'This bid will be delete', {
handleOk: async () => { key: "start_bid_time",
await deleteBid(bid); title: "Start bid",
typeFilter: "text",
if (refTableFn.current?.fetchData) { renderRow(row) {
refTableFn.current.fetchData();
}
},
});
};
const handleToggleBid = async (bid: IBid) => {
const isEnable = bid.status === 'biding' ? true : bid.status === 'out-bid' ? false : true;
setConfirm({
title: (isEnable ? 'Disable ' : 'Enable ') + 'ID: ' + bid.id,
message: 'This bid will be ' + (isEnable ? 'disable ' : 'enable '),
handleOk: async () => {
await toggleBid(bid);
if (refTableFn.current?.fetchData) {
refTableFn.current.fetchData();
}
},
okButton: {
value: isEnable ? 'Disable ' : 'Enable ',
color: isEnable ? 'red' : 'blue',
},
});
};
const table = useMemo(() => {
return ( return (
<Table <Tooltip hidden={!row.start_bid_time} label={row.start_bid_time}>
onClickRow={(row) => { <Text size="sm">
window.open(row.url, '_blank'); {row.start_bid_time
}} ? formatTime(row.start_bid_time, "HH:mm:ss DD/MM/YYYY")
tableChildProps={{ : "None"}
trbody: { </Text>
className: 'cursor-pointer', </Tooltip>
},
}}
actionsOptions={{
actions: [
{
key: 'add',
title: 'Add',
callback: () => {
bidModal.open();
},
},
{
key: 'delete',
title: 'Delete',
callback: (data) => {
if (!data.length) return;
setConfirm({
title: 'Delete',
message: `${data.length} will be delete`,
handleOk: async () => {
const result = await deletesBid(data);
if (!result) return;
if (refTableFn.current.fetchData) {
refTableFn.current.fetchData();
}
},
});
},
disabled: (data) => data.length <= 0,
},
],
}}
refTableFn={refTableFn}
striped
showLoading={true}
highlightOnHover
styleDefaultHead={{
justifyContent: 'flex-start',
width: 'fit-content',
}}
options={{
query: getBids,
pathToData: 'data.data',
keyOptions: {
last_page: 'lastPage',
per_page: 'perPage',
from: 'from',
to: 'to',
total: 'total',
},
}}
rows={[]}
withColumnBorders
showChooses={true}
withTableBorder
columns={columns}
actions={{
title: <Box className="w-full text-center">Action</Box>,
body: (row) => {
return (
<Menu shadow="md" width={200}>
<Menu.Target>
<Box onClick={(e) => e.stopPropagation()} className="flex w-full items-center justify-center">
<ActionIcon size="sm" variant="light">
<IconMenu size={14} />
</ActionIcon>
</Box>
</Menu.Target>
<Menu.Dropdown onClick={(e) => e.stopPropagation()}>
<Menu.Item
onClick={() => {
setClickData(row);
bidModal.open();
}}
leftSection={<IconEdit size={14} />}
>
Edit
</Menu.Item>
<Menu.Item
onClick={() => {
setClickData(row);
historiesModel.open();
}}
leftSection={<IconHistory size={14} />}
>
Histories
</Menu.Item>
{['https://www.grays.com'].includes(row?.web_bid.origin_url) && (
<Menu.Item
onClick={() => {
setClickData(row);
historiesGraysApiModel.open();
}}
leftSection={<IconHammer size={14} />}
>
Bids
</Menu.Item>
)}
<Menu.Item
disabled={row.status === 'win-bid'}
onClick={() => handleToggleBid(row)}
leftSection={row.status === 'biding' ? <IconAdOff size={14} /> : <IconAd size={14} />}
>
{row.status === 'biding' ? 'Disable' : 'Enable'}
</Menu.Item>
<Menu.Item onClick={() => handleDelete(row)} leftSection={<IconTrash color="red" size={14} />}>
Delete
</Menu.Item>
</Menu.Dropdown>
</Menu>
);
},
}}
rowKey="id"
/>
); );
// eslint-disable-next-line react-hooks/exhaustive-deps },
}, []); },
{
key: "close_time",
title: "Close time",
typeFilter: "text",
renderRow(row) {
return (
<Tooltip hidden={!row.close_time} label={row.close_time}>
<Text size="sm">
{row.close_time
? formatTime(row.close_time, "HH:mm:ss DD/MM/YYYY")
: "None"}
</Text>
</Tooltip>
);
},
},
{
key: "status",
title: "Status",
typeFilter: "text",
renderRow(row) {
return (
<Box className="flex items-center justify-center">
<Badge color={mappingStatusColors[row.status]} size="sm">
{row.status}
</Badge>
</Box>
);
},
},
];
const handleDelete = (bid: IBid) => {
setConfirm({
title: "Delete ?",
message: "This bid will be delete",
handleOk: async () => {
await deleteBid(bid);
if (refTableFn.current?.fetchData) {
refTableFn.current.fetchData();
}
},
});
};
const handleToggleBid = async (bid: IBid) => {
const isEnable =
bid.status === "biding" ? true : bid.status === "out-bid" ? false : true;
setConfirm({
title: (isEnable ? "Disable " : "Enable ") + "ID: " + bid.id,
message: "This bid will be " + (isEnable ? "disable " : "enable "),
handleOk: async () => {
await toggleBid(bid);
if (refTableFn.current?.fetchData) {
refTableFn.current.fetchData();
}
},
okButton: {
value: isEnable ? "Disable " : "Enable ",
color: isEnable ? "red" : "blue",
},
});
};
const table = useMemo(() => {
return ( return (
<Box> <Table
{table} onClickRow={(row) => {
window.open(row.url, "_blank");
}}
tableChildProps={{
trbody: {
className: "cursor-pointer",
},
}}
actionsOptions={{
actions: [
{
key: "add",
title: "Add",
callback: () => {
bidModal.open();
},
},
{
key: "delete",
title: "Delete",
callback: (data) => {
if (!data.length) return;
setConfirm({
title: "Delete",
message: `${data.length} will be delete`,
handleOk: async () => {
const result = await deletesBid(data);
<ShowHistoriesModal if (!result) return;
opened={openedHistories} if (refTableFn.current.fetchData) {
onClose={() => { refTableFn.current.fetchData();
historiesModel.close();
setClickData(null);
}}
data={clickData}
/>
<BidModal
onUpdated={() => {
if (refTableFn.current?.fetchData) {
refTableFn.current.fetchData();
} }
},
});
},
disabled: (data) => data.length <= 0,
},
],
}}
refTableFn={refTableFn}
striped
showLoading={true}
highlightOnHover
styleDefaultHead={{
justifyContent: "flex-start",
width: "fit-content",
}}
options={{
query: getBids,
pathToData: "data.data",
keyOptions: {
last_page: "lastPage",
per_page: "perPage",
from: "from",
to: "to",
total: "total",
},
}}
rows={[]}
withColumnBorders
showChooses={true}
withTableBorder
columns={columns}
actions={{
title: <Box className="w-full text-center">Action</Box>,
body: (row) => {
return (
<Menu shadow="md" width={200}>
<Menu.Target>
<Box
onClick={(e) => e.stopPropagation()}
className="flex w-full items-center justify-center"
>
<ActionIcon size="sm" variant="light">
<IconMenu size={14} />
</ActionIcon>
</Box>
</Menu.Target>
setClickData(null); <Menu.Dropdown onClick={(e) => e.stopPropagation()}>
}} <Menu.Item
opened={openedBid} onClick={() => {
onClose={() => { setClickData(row);
bidModal.close(); bidModal.open();
}}
leftSection={<IconEdit size={14} />}
>
Edit
</Menu.Item>
setClickData(null); <Menu.Item
}} onClick={() => {
data={clickData} setClickData(row);
/> historiesModel.open();
}}
leftSection={<IconHistory size={14} />}
>
Histories
</Menu.Item>
{haveHistories.includes(row?.web_bid.origin_url) && (
<Menu.Item
onClick={() => {
setClickData(row);
switch (row.web_bid.origin_url) {
case constants.grays: {
historiesGraysApiModel.open();
break;
}
case constants.pickles: {
historiesPicklesApiModel.open();
break;
}
default: {
historiesGraysApiModel.open();
}
}
}}
leftSection={<IconHammer size={14} />}
>
Bids
</Menu.Item>
)}
<ShowHistoriesBidGraysApiModal <Menu.Item
onUpdated={() => { disabled={row.status === "win-bid"}
if (refTableFn.current?.fetchData) { onClick={() => handleToggleBid(row)}
refTableFn.current.fetchData(); leftSection={
row.status === "biding" ? (
<IconAdOff size={14} />
) : (
<IconAd size={14} />
)
} }
>
{row.status === "biding" ? "Disable" : "Enable"}
</Menu.Item>
setClickData(null); <Menu.Item
}} onClick={() => handleDelete(row)}
opened={openedHistoriesGraysApi} leftSection={<IconTrash color="red" size={14} />}
onClose={() => { >
historiesGraysApiModel.close(); Delete
</Menu.Item>
setClickData(null); </Menu.Dropdown>
}} </Menu>
data={clickData} );
/> },
</Box> }}
rowKey="id"
/>
); );
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<Box>
{table}
<ShowHistoriesModal
opened={openedHistories}
onClose={() => {
historiesModel.close();
setClickData(null);
}}
data={clickData}
/>
<BidModal
onUpdated={() => {
if (refTableFn.current?.fetchData) {
refTableFn.current.fetchData();
}
setClickData(null);
}}
opened={openedBid}
onClose={() => {
bidModal.close();
setClickData(null);
}}
data={clickData}
/>
{/* Grays */}
{openedHistoriesGraysApi && (
<ShowHistoriesBidGraysApiModal
onUpdated={() => {
if (refTableFn.current?.fetchData) {
refTableFn.current.fetchData();
}
setClickData(null);
}}
opened={openedHistoriesGraysApi}
onClose={() => {
historiesGraysApiModel.close();
setClickData(null);
}}
data={clickData}
/>
)}
{openedHistoriesPicklesApi && (
<ShowHistoriesBidPicklesApiModal
onUpdated={() => {
if (refTableFn.current?.fetchData) {
refTableFn.current.fetchData();
}
setClickData(null);
}}
opened={true}
onClose={() => {
historiesPicklesApiModel.close();
setClickData(null);
}}
data={clickData}
/>
)}
</Box>
);
} }

View File

@ -1,132 +1,186 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
import { Box, Button, LoadingOverlay, Text, Title } from '@mantine/core'; import {
import { useEffect, useRef, useState } from 'react'; Box,
import io from 'socket.io-client'; Button,
import { WorkingPage } from '../components/dashboard'; LoadingOverlay,
import { IBid, IWebBid } from '../system/type'; Text,
import { checkStatus } from '../apis/auth'; Title,
import { IconPower, IconRestore } from '@tabler/icons-react'; Tooltip,
import { useConfirmStore } from '../lib/zustand/use-confirm'; } from "@mantine/core";
import { resetTool, shutdownTool } from '../apis/dashboard'; import { useEffect, useRef, useState } from "react";
import io from "socket.io-client";
import { WorkingPage } from "../components/dashboard";
import { IBid, IWebBid } from "../system/type";
import { checkStatus } from "../apis/auth";
import { IconPower, IconRestore } from "@tabler/icons-react";
import { useConfirmStore } from "../lib/zustand/use-confirm";
import { getStatusTool, resetTool, shutdownTool } from "../apis/dashboard";
import { cn } from "../utils";
import { useStatusToolStore } from "../lib/zustand/use-status-tool-store";
const socket = io(`${import.meta.env.VITE_SOCKET_URL}/admin-bid-ws`, { const socket = io(`${import.meta.env.VITE_SOCKET_URL}/admin-bid-ws`, {
autoConnect: true, autoConnect: true,
transports: ['websocket'], transports: ["websocket"],
}); });
export default function DashBoard() { export default function DashBoard() {
const [workingData, setWorkingData] = useState<(IWebBid & { type: string })[] | (IBid & { type: string })[]>([]); const [workingData, setWorkingData] = useState<
const { setConfirm } = useConfirmStore(); (IWebBid & { type: string })[] | (IBid & { type: string })[]
>([]);
const { setConfirm } = useConfirmStore();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const RETRY_CONNECT = useRef(2); const { setStatusTool, statusTool } = useStatusToolStore();
const RETRY_CONNECT = useRef(2);
useEffect(() => {
socket.connect();
socket.on("connect", () => {
socket.emit("getBidsData");
});
socket.on("disconnect", async () => {
if (RETRY_CONNECT.current > 0) {
await checkStatus();
useEffect(() => {
socket.connect(); socket.connect();
socket.on('connect', () => { RETRY_CONNECT.current--;
socket.emit('getBidsData'); return;
}); }
});
socket.on('disconnect', async () => { socket.on("adminBidsUpdated", (data: IWebBid[]) => {
if (RETRY_CONNECT.current > 0) { const array = data.reduce((prev, cur) => {
await checkStatus(); if (cur.children?.length > 0) {
prev = [...prev, ...cur.children];
}
prev.push(cur);
return prev;
}, [] as any[]);
socket.connect(); const newData = array.map((item) => {
if (item.children) {
return {
...item,
type: "API_BID",
};
}
RETRY_CONNECT.current--; return {
return; ...item,
} type: "PRODUCT_TAB",
});
socket.on('adminBidsUpdated', (data: IWebBid[]) => {
const array = data.reduce((prev, cur) => {
if (cur.children?.length > 0) {
prev = [...prev, ...cur.children];
}
prev.push(cur);
return prev;
}, [] as any[]);
const newData = array.map((item) => {
if (item.children) {
return {
...item,
type: 'API_BID',
};
}
return {
...item,
type: 'PRODUCT_TAB',
};
});
setWorkingData(newData);
});
return () => {
console.log('🔌 Cleanup WebSocket listeners...');
socket.off('adminBidsUpdated');
socket.off('working');
socket.off('connect');
socket.off('disconnect');
socket.disconnect();
}; };
}, []); });
setWorkingData(newData);
});
const handleResetTool = () => { return () => {
setConfirm({ console.log("🔌 Cleanup WebSocket listeners...");
handleOk: async () => { socket.off("adminBidsUpdated");
setLoading(true); socket.off("working");
await resetTool(); socket.off("connect");
setLoading(false); socket.off("disconnect");
}, socket.disconnect();
title: 'Confirm tool reset', };
message: 'Are you sure you want to reset this tool? All current processes will be stopped and restarted.', }, []);
okButton: { value: 'Ok', color: 'blue' },
}); useEffect(() => {
const statusTool = async () => {
const result = await getStatusTool();
if (result?.data) {
setStatusTool(result?.data);
} else {
setStatusTool(false);
}
}; };
const handleShutdownTool = () => { const intervalId = setInterval(statusTool, 5000);
setConfirm({
handleOk: async () => { return () => {
setLoading(true); clearInterval(intervalId);
await shutdownTool();
setLoading(false);
},
title: 'Confirm tool shutdown',
message: 'Are you sure you want to shut down this tool? All running processes will be stopped and the tool will go offline.',
okButton: { value: 'Ok', color: 'blue' },
});
}; };
}, []);
return ( const handleResetTool = () => {
<Box> setConfirm({
<Box className="flex items-center justify-between"> handleOk: async () => {
<Title order={2} mb="md"> setLoading(true);
Admin Dashboard await resetTool();
</Title> setLoading(false);
<Box className="flex gap-2"> },
<Button onClick={handleResetTool} leftSection={<IconRestore size={16} />} size="xs"> title: "Confirm tool reset",
Reset tool message:
</Button> "Are you sure you want to reset this tool? All current processes will be stopped and restarted.",
<Button onClick={handleShutdownTool} leftSection={<IconPower size={16} />} color="red" size="xs"> okButton: { value: "Ok", color: "blue" },
Shutdown tool });
</Button> };
</Box>
</Box>
<Box className="grid grid-cols-4 gap-4">
{workingData.length > 0 && workingData.map((item, index) => <WorkingPage socket={socket} data={item} key={item.id + index} />)}
{workingData.length <= 0 && ( const handleShutdownTool = () => {
<Box className="flex items-center justify-center col-span-4"> setConfirm({
<Text>No Pages</Text> handleOk: async () => {
</Box> setLoading(true);
)} await shutdownTool();
</Box> setLoading(false);
},
title: "Confirm tool shutdown",
message:
"Are you sure you want to shut down this tool? All running processes will be stopped and the tool will go offline.",
okButton: { value: "Ok", color: "blue" },
});
};
<LoadingOverlay visible={loading} /> return (
</Box> <Box>
); <Box className="flex items-center justify-between">
<Title order={2} mb="md">
Admin Dashboard
</Title>
<Tooltip label={typeof statusTool === "string" && statusTool}>
<Box
className={cn("flex gap-2 border py-3 px-4 rounded-md", {
["border-green-800"]: statusTool || statusTool === "online",
["border-red-800"]: !statusTool || statusTool !== "online",
})}
>
<Button
color={statusTool === "online" ? "blue" : "green"}
onClick={handleResetTool}
leftSection={<IconRestore size={16} />}
size="xs"
>
{statusTool === "online" ? "Reset tool" : "Start tool"}
</Button>
<Button
onClick={handleShutdownTool}
leftSection={<IconPower size={16} />}
color="red"
size="xs"
>
Shutdown tool
</Button>
</Box>
</Tooltip>
</Box>
<Box className="grid grid-cols-4 gap-4 mt-5">
{workingData.length > 0 &&
workingData.map((item, index) => (
<WorkingPage socket={socket} data={item} key={item.id + index} />
))}
{workingData.length <= 0 && (
<Box className="flex items-center justify-center col-span-4">
<Text>No Pages</Text>
</Box>
)}
</Box>
<LoadingOverlay visible={loading} />
</Box>
);
} }

View File

@ -1,112 +1,153 @@
/* eslint-disable @typescript-eslint/no-unused-vars */ /* eslint-disable @typescript-eslint/no-unused-vars */
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
import { clsx, type ClassValue } from 'clsx'; import { clsx, type ClassValue } from "clsx";
import { twMerge } from 'tailwind-merge'; import { twMerge } from "tailwind-merge";
import moment from 'moment'; import moment from "moment";
export function cn(...args: ClassValue[]) { export function cn(...args: ClassValue[]) {
return twMerge(clsx(args)); return twMerge(clsx(args));
} }
export const formatTime = (time: string, patent = 'DD/MM/YYYY') => { export const formatTime = (time: string, patent = "DD/MM/YYYY") => {
return moment(time).format(patent); return moment(time).format(patent);
}; };
export function removeFalsyValues<T extends Record<string, any>>(obj: T, excludeKeys: (keyof T)[] = []): Partial<T> { export function removeFalsyValues<T extends Record<string, any>>(
return Object.entries(obj).reduce((acc, [key, value]) => { obj: T,
if (value || excludeKeys.includes(key as keyof T)) { excludeKeys: (keyof T)[] = []
acc[key as keyof T] = value; ): Partial<T> {
} return Object.entries(obj).reduce((acc, [key, value]) => {
return acc; if (value || excludeKeys.includes(key as keyof T)) {
}, {} as Partial<T>); acc[key as keyof T] = value;
}
return acc;
}, {} as Partial<T>);
} }
export function isValidJSON(str: string): boolean { export function isValidJSON(str: string): boolean {
if (!str || str.length <= 0) return false; if (!str || str.length <= 0) return false;
try { try {
JSON.parse(str); JSON.parse(str);
return true; return true;
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (e) { } catch (e) {
return false; return false;
} }
} }
export function copyToClipboard(text: string, onSuccess?: () => void): void { export function copyToClipboard(text: string, onSuccess?: () => void): void {
if (!navigator.clipboard) { if (!navigator.clipboard) {
const textarea = document.createElement('textarea'); const textarea = document.createElement("textarea");
textarea.value = text; textarea.value = text;
textarea.style.position = 'fixed'; textarea.style.position = "fixed";
document.body.appendChild(textarea); document.body.appendChild(textarea);
textarea.focus(); textarea.focus();
textarea.select(); textarea.select();
try { try {
document.execCommand('copy'); document.execCommand("copy");
if (onSuccess) onSuccess(); if (onSuccess) onSuccess();
} catch (err) { } catch (err) {
console.error('Không thể copy nội dung: ', err); console.error("Không thể copy nội dung: ", err);
}
document.body.removeChild(textarea);
} else {
navigator.clipboard
.writeText(text)
.then(() => {
if (onSuccess) onSuccess();
})
.catch((err) => console.error('Lỗi khi copy nội dung: ', err));
} }
document.body.removeChild(textarea);
} else {
navigator.clipboard
.writeText(text)
.then(() => {
if (onSuccess) onSuccess();
})
.catch((err) => console.error("Lỗi khi copy nội dung: ", err));
}
} }
export function base64ToFile(base64String: string, fileName: string): File { export function base64ToFile(base64String: string, fileName: string): File {
const [header, base64Content] = base64String.split(','); const [header, base64Content] = base64String.split(",");
const mimeTypeMatch = header.match(/:(.*?);/); const mimeTypeMatch = header.match(/:(.*?);/);
if (!mimeTypeMatch || mimeTypeMatch.length < 2) { if (!mimeTypeMatch || mimeTypeMatch.length < 2) {
throw new Error('Invalid base64 string'); throw new Error("Invalid base64 string");
} }
const mimeType = mimeTypeMatch[1]; const mimeType = mimeTypeMatch[1];
const binaryString = atob(base64Content); const binaryString = atob(base64Content);
const byteArray = new Uint8Array(binaryString.length); const byteArray = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) { for (let i = 0; i < binaryString.length; i++) {
byteArray[i] = binaryString.charCodeAt(i); byteArray[i] = binaryString.charCodeAt(i);
} }
return new File([byteArray], fileName, { type: mimeType }); return new File([byteArray], fileName, { type: mimeType });
} }
export function toSlug(str: string, maxLength = 60): string { export function toSlug(str: string, maxLength = 60): string {
if (typeof str !== 'string') return ''; // Kiểm tra giá trị đầu vào if (typeof str !== "string") return ""; // Kiểm tra giá trị đầu vào
// Kiểm tra nếu môi trường hỗ trợ `normalize` // Kiểm tra nếu môi trường hỗ trợ `normalize`
const normalizedStr = str.normalize ? str.normalize('NFD') : str; const normalizedStr = str.normalize ? str.normalize("NFD") : str;
return normalizedStr return normalizedStr
.replace(/[\u0300-\u036f]/g, '') // Xóa dấu .replace(/[\u0300-\u036f]/g, "") // Xóa dấu
.replace(/[^a-zA-Z0-9\s-]/g, '') // Chỉ giữ chữ cái, số, khoảng trắng và dấu "-" .replace(/[^a-zA-Z0-9\s-]/g, "") // Chỉ giữ chữ cái, số, khoảng trắng và dấu "-"
.trim() // Xóa khoảng trắng đầu/cuối .trim() // Xóa khoảng trắng đầu/cuối
.replace(/\s+/g, '-') // Thay khoảng trắng bằng "-" .replace(/\s+/g, "-") // Thay khoảng trắng bằng "-"
.replace(/-+/g, '-') // Gộp nhiều dấu "-" thành 1 .replace(/-+/g, "-") // Gộp nhiều dấu "-" thành 1
.toLowerCase() // Chuyển về chữ thường .toLowerCase() // Chuyển về chữ thường
.slice(0, maxLength) // Giới hạn độ dài .slice(0, maxLength) // Giới hạn độ dài
.replace(/^-+|-+$/g, ''); // Xóa "-" đầu/cuối .replace(/^-+|-+$/g, ""); // Xóa "-" đầu/cuối
} }
export function estimateReadingTimeInSeconds(content: string, wordsPerMinute = 200): number { export function estimateReadingTimeInSeconds(
if (!content || typeof content !== 'string') return 0; content: string,
wordsPerMinute = 200
): number {
if (!content || typeof content !== "string") return 0;
const wordCount = content.trim().split(/\s+/).length; const wordCount = content.trim().split(/\s+/).length;
return Math.ceil((wordCount / wordsPerMinute) * 60); return Math.ceil((wordCount / wordsPerMinute) * 60);
} }
export function extractDomain(url: string): string | null { export function extractDomain(url: string): string | null {
try { try {
const parsedUrl = new URL(url); const parsedUrl = new URL(url);
return parsedUrl.origin; return parsedUrl.origin;
} catch (error) { } catch (error) {
return null; return null;
} }
}
// Hash chuỗi thành số nguyên
export function hashStringToInt(str: string): number {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash);
hash = hash & hash; // convert to 32bit integer
}
return Math.abs(hash);
}
// Biến số thành màu HEX
export function intToHexColor(int: number): string {
const r = (int >> 16) & 0xff;
const g = (int >> 8) & 0xff;
const b = int & 0xff;
return `#${[r, g, b].map((x) => x.toString(16).padStart(2, "0")).join("")}`;
}
export function stringToColor(str: string): string {
const colorPalette = [
"#FF6B6B",
"#FFD93D",
"#FF9F1C",
"#F76C6C",
"#6BCB77",
"#4ECDC4",
"#F7B801",
"#FF6F91",
"#00C9A7",
];
const hash = hashStringToInt(str);
const index = hash % colorPalette.length;
return colorPalette[index];
} }

View File

@ -1 +1 @@
{"createdAt":1744861741554} {"createdAt":1745827424853}

View File

@ -2,18 +2,53 @@ import { Injectable } from '@nestjs/common';
import axios from 'axios'; import axios from 'axios';
import AppResponse from 'src/response/app-response'; import AppResponse from 'src/response/app-response';
import { Bid } from '../entities/bid.entity'; import { Bid } from '../entities/bid.entity';
import { BidsService } from '../services/bids.service';
@Injectable() @Injectable()
export class GraysApi { 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); constructor(private readonly bidsService: BidsService){}
async getHistoriesBid(lot_id: Bid['lot_id']) {
const bid= await this.bidsService.bidsRepo.findOne({where: {lot_id, }, relations: {web_bid: true}})
try {
switch(bid.web_bid.origin_url){
case 'https://www.grays.com': {
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);
}
return AppResponse.toResponse([])
}
case 'https://www.pickles.com.au': {
const response = await axios({
url: `https://www.pickles.com.au/PWR-Web/services/api/bidHistoryService/bidHistory?item=${lot_id}`,
});
if (response.data) {
return AppResponse.toResponse(response.data);
}
return AppResponse.toResponse([])
}
default:
return AppResponse.toResponse([])
} }
} catch (error) { } catch (error) {
return AppResponse.toResponse([]); return AppResponse.toResponse([]);
} }

View File

@ -1,4 +1,4 @@
import { Controller, Post } from '@nestjs/common'; import { Controller, Get, Post } from '@nestjs/common';
import { DashboardService } from '../../services/dashboard.service'; import { DashboardService } from '../../services/dashboard.service';
@Controller('admin/dashboards') @Controller('admin/dashboards')
@ -14,4 +14,9 @@ export class AdminDashboardController {
async shutdownTool() { async shutdownTool() {
return await this.dashboardService.shutdownTool(); return await this.dashboardService.shutdownTool();
} }
@Get('status-tool')
async statusTool() {
return await this.dashboardService.statusTool();
}
} }

View File

@ -21,6 +21,7 @@ import { BidsService } from '../../services/bids.service';
import { WebBidsService } from '../../services/web-bids.service'; import { WebBidsService } from '../../services/web-bids.service';
import { Event } from '../../utils/events'; import { Event } from '../../utils/events';
import AppResponse from '@/response/app-response'; import AppResponse from '@/response/app-response';
import { ClientUpdateLoginStatusDto } from '../../dto/bid/client-update-login-status.dto';
@Controller('bids') @Controller('bids')
export class BidsController { export class BidsController {
@ -68,17 +69,24 @@ export class BidsController {
return this.bidsService.updateStatusWork(id, type, image); return this.bidsService.updateStatusWork(id, type, image);
} }
@Post('update-login-status')
async updateLoginStatus(
@Body() data: ClientUpdateLoginStatusDto
) {
return await this.bidsService.emitLoginStatus(data)
}
@Post('test') @Post('test')
async test(@Body('code') code: string) { async test(@Body('code') code: string) {
const webBid = await this.webBidService.webBidRepo.findOne({ const webBid = await this.webBidService.webBidRepo.findOne({
// where: { id: 9 }, where: { id: 4 },
where: { id: 8 }, // where: { id: 1 },
}); });
this.eventEmitter.emit(Event.verifyCode(webBid), { this.eventEmitter.emit(Event.verifyCode(webBid), {
code, code,
// name: 'LAWSONS', name: 'LAWSONS',
name: 'LANGTONS', // name: 'LANGTONS',
web_bid: plainToClass(WebBid, webBid), web_bid: plainToClass(WebBid, webBid),
}); });

View File

@ -0,0 +1,12 @@
import { IsBoolean, IsNumber, IsObject, IsOptional, IsString } from 'class-validator';
import { WebBid } from '../../entities/wed-bid.entity';
export class ClientUpdateLoginStatusDto {
@IsObject()
data: WebBid
@IsBoolean()
login_status: boolean
}

View File

@ -47,6 +47,17 @@ export class AdminBidGateway implements OnGatewayConnection {
this.server.emit(Event.WORKING, data); this.server.emit(Event.WORKING, data);
}); });
this.eventEmitter.onAny(
(
event: string,
payload: { login_status: string; data: WebBid },
) => {
if (!event.startsWith(Event.LOGIN_STATUS)) return;
this.server.emit(Event.statusLogin(payload.data), payload);
},
);
// IMAP // IMAP
this.imapService.connectIMAP(); this.imapService.connectIMAP();
} }

View File

@ -32,6 +32,7 @@ import { WebBidsService } from './web-bids.service';
import { NotificationService } from '@/modules/notification/notification.service'; import { NotificationService } from '@/modules/notification/notification.service';
import { Event } from '../utils/events'; import { Event } from '../utils/events';
import _ from 'lodash'; import _ from 'lodash';
import { ClientUpdateLoginStatusDto } from '../dto/bid/client-update-login-status.dto';
@Injectable() @Injectable()
export class BidsService { export class BidsService {
@ -273,7 +274,7 @@ export class BidsService {
const result = await this.bidsRepo.save({ const result = await this.bidsRepo.save({
...bid, ...bid,
...data, ...data,
current_price: Math.max(data.current_price, bid.current_price), current_price: Math.max(data?.current_price || 0, bid.current_price),
updated_at: new Date(), // Cập nhật timestamp updated_at: new Date(), // Cập nhật timestamp
}); });
@ -508,4 +509,12 @@ export class BidsService {
return AppResponse.toResponse(files); return AppResponse.toResponse(files);
} }
async emitLoginStatus(data: ClientUpdateLoginStatusDto){
this.eventEmitter.emit(Event.statusLogin(data.data), data)
return AppResponse.toResponse(true)
}
} }

View File

@ -7,7 +7,7 @@ export class DashboardService {
private readonly tool_name = 'auto-bid-tool'; private readonly tool_name = 'auto-bid-tool';
async resetToolByName(toolName: string): Promise<string> { async resetProcessByName(toolName: string): Promise<string> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
// Lấy danh sách process đang chạy // Lấy danh sách process đang chạy
exec('pm2 jlist', (error, stdout, stderr) => { exec('pm2 jlist', (error, stdout, stderr) => {
@ -41,7 +41,35 @@ export class DashboardService {
}); });
} }
async shutdownToolByName(toolName: string): Promise<string> { async getStatusProcessByName(toolName: string): Promise<string> {
return new Promise((resolve, reject) => {
exec('pm2 jlist', (error, stdout, stderr) => {
if (error) {
return reject(`Error get list process: ${stderr}`);
}
try {
const processList = JSON.parse(stdout);
const targetProcess = processList.find(
(proc: any) => proc.name === toolName,
);
if (!targetProcess) {
return reject(`Not found process for name "${toolName}"`);
}
const status = targetProcess.pm2_env?.status || 'unknown';
return resolve(status); // Trả về: 'online', 'stopped', 'errored', etc.
} catch (parseErr) {
reject(`Error parse JSON output: ${parseErr}`);
}
});
});
}
async shutdownProcessByName(toolName: string): Promise<string> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
// Lấy danh sách process đang chạy // Lấy danh sách process đang chạy
exec('pm2 jlist', (error, stdout, stderr) => { exec('pm2 jlist', (error, stdout, stderr) => {
@ -77,7 +105,7 @@ export class DashboardService {
async resetTool() { async resetTool() {
try { try {
await this.resetToolByName(this.tool_name); await this.resetProcessByName(this.tool_name);
return AppResponse.toResponse(true); return AppResponse.toResponse(true);
} catch (error) { } catch (error) {
@ -87,11 +115,22 @@ export class DashboardService {
async shutdownTool() { async shutdownTool() {
try { try {
await this.shutdownToolByName(this.tool_name); await this.shutdownProcessByName(this.tool_name);
return AppResponse.toResponse(true); return AppResponse.toResponse(true);
} catch (error) { } catch (error) {
return AppResponse.toResponse(false); return AppResponse.toResponse(false);
} }
} }
async statusTool() {
try {
const result = await this.getStatusProcessByName(this.tool_name);
return AppResponse.toResponse(result);
} catch (error) {
return AppResponse.toResponse(false);
}
}
} }

View File

@ -6,8 +6,13 @@ export class Event {
public static BIDS_UPDATED = 'bidsUpdated'; public static BIDS_UPDATED = 'bidsUpdated';
public static ADMIN_BIDS_UPDATED = 'adminBidsUpdated'; public static ADMIN_BIDS_UPDATED = 'adminBidsUpdated';
public static WEB_UPDATED = 'webUpdated'; public static WEB_UPDATED = 'webUpdated';
public static LOGIN_STATUS = 'login-status';
public static verifyCode(data: WebBid) { public static verifyCode(data: WebBid) {
return `${this.VERIFY_CODE}.${data.origin_url}`; return `${this.VERIFY_CODE}.${data.origin_url}`;
} }
public static statusLogin(data: WebBid) {
return `${this.LOGIN_STATUS}.${data.origin_url}`;
}
} }

View File

@ -7,18 +7,21 @@ import { TypeOrmModule } from '@nestjs/typeorm';
TypeOrmModule.forRootAsync({ TypeOrmModule.forRootAsync({
imports: [ConfigModule], imports: [ConfigModule],
inject: [ConfigService], inject: [ConfigService],
useFactory: (configService: ConfigService) => ({ useFactory: (configService: ConfigService) => {
type: 'mysql',
host: configService.get<string>('DB_HOST'), return {
port: configService.get<number>('DB_PORT'), type: 'mysql',
username: configService.get<string>('DB_USERNAME'), host: configService.get<string>('DB_HOST'),
password: configService.get<string>('DB_PASSWORD'), port: configService.get<number>('DB_PORT'),
database: configService.get<string>('DB_NAME'), username: configService.get<string>('DB_USERNAME'),
charset: 'utf8mb4_unicode_ci', password: configService.get<string>('DB_PASSWORD'),
entities: ['dist/**/*.entity{.ts,.js}'], database: configService.get<string>('DB_NAME'),
synchronize: charset: 'utf8mb4_unicode_ci',
configService.get<string>('ENVIRONMENT') === 'prod' ? false : true, entities: ['dist/**/*.entity{.ts,.js}'],
}), synchronize:
configService.get<string>('ENVIRONMENT') === 'prod' ? false : true,
}
},
}), }),
], ],
}) })

View File

@ -12,6 +12,10 @@ export function extractModelId(url: string): string | null {
const match = url.split('_'); const match = url.split('_');
return match ? match[1] : null; return match ? match[1] : null;
} }
case 'https://www.pickles.com.au': {
const model = url.split('/').pop();
return model ? model : null;
}
} }
} }

View File

@ -0,0 +1,16 @@
module.exports = {
apps: [
{
name: "auto-bid-tool",
script: "./index.js",
instances: 1,
exec_mode: "fork",
watch: false,
log_date_format: "YYYY-MM-DD HH:mm:ss",
output: "./logs/out.log",
error: "./logs/error.log",
merge_logs: true,
max_memory_restart: "12G",
},
],
};

View File

@ -1,11 +1,17 @@
import 'dotenv/config'; import "dotenv/config";
import _ from 'lodash'; import _ from "lodash";
import pLimit from 'p-limit'; import pLimit from "p-limit";
import { io } from 'socket.io-client'; import { io } from "socket.io-client";
import { createApiBid, createBidProduct, deleteProfile, shouldUpdateProductTab } from './service/app-service.js'; import {
import browser from './system/browser.js'; createApiBid,
import configs from './system/config.js'; createBidProduct,
import { delay, isTimeReached, safeClosePage } from './system/utils.js'; deleteProfile,
shouldUpdateProductTab,
} from "./service/app-service.js";
import browser from "./system/browser.js";
import configs from "./system/config.js";
import { delay, isTimeReached, safeClosePage } from "./system/utils.js";
import { updateLoginStatus } from "./system/apis/bid.js";
global.IS_CLEANING = true; global.IS_CLEANING = true;
@ -14,260 +20,335 @@ let MANAGER_BIDS = [];
const activeTasks = new Set(); const activeTasks = new Set();
const handleUpdateProductTabs = (data) => { const handleUpdateProductTabs = (data) => {
if (!Array.isArray(data)) { if (!Array.isArray(data)) {
console.log('Data must be array'); console.log("Data must be array");
return; return;
} }
const managerBidMap = new Map(MANAGER_BIDS.map((bid) => [bid.id, bid])); const managerBidMap = new Map(MANAGER_BIDS.map((bid) => [bid.id, bid]));
const newDataManager = data.map(({ children, ...web }) => { const newDataManager = data.map(({ children, ...web }) => {
const prevApiBid = managerBidMap.get(web.id); const prevApiBid = managerBidMap.get(web.id);
const newChildren = children.map((item) => { const newChildren = children.map((item) => {
const prevProductTab = prevApiBid?.children.find((i) => i.id === item.id); const prevProductTab = prevApiBid?.children.find((i) => i.id === item.id);
if (prevProductTab) { if (prevProductTab) {
prevProductTab.setNewData(item); prevProductTab.setNewData(item);
return prevProductTab; return prevProductTab;
} }
return createBidProduct(web, item); return createBidProduct(web, item);
});
if (prevApiBid) {
prevApiBid.setNewData({ children: newChildren, ...web });
return prevApiBid;
}
return createApiBid({ ...web, children: newChildren });
}); });
MANAGER_BIDS = newDataManager; if (prevApiBid) {
prevApiBid.setNewData({ children: newChildren, ...web });
return prevApiBid;
}
return createApiBid({ ...web, children: newChildren });
});
MANAGER_BIDS = newDataManager;
}; };
const tracking = async () => { const tracking = async () => {
console.log('🚀 Tracking process started...'); console.log("🚀 Tracking process started...");
while (true) { while (true) {
try { try {
console.log('🔍 Scanning active bids...'); console.log("🔍 Scanning active bids...");
const productTabs = _.flatMap(MANAGER_BIDS, 'children'); const productTabs = _.flatMap(MANAGER_BIDS, "children");
await Promise.allSettled( await Promise.allSettled(
MANAGER_BIDS.filter((bid) => !bid.page_context).map((apiBid) => { MANAGER_BIDS.filter((bid) => !bid.page_context).map((apiBid) => {
console.log(`🎧 Listening to events for API Bid ID: ${apiBid.id}`); console.log(`🎧 Listening to events for API Bid ID: ${apiBid.id}`);
return apiBid.listen_events(); return apiBid.listen_events();
}), })
);
Promise.allSettled(
productTabs.map(async (productTab) => {
console.log(`📌 Processing Product ID: ${productTab.id}`);
// Xác định parent context
if (!productTab.parent_browser_context) {
const parent = _.find(MANAGER_BIDS, { id: productTab.web_bid.id });
productTab.parent_browser_context = parent?.browser_context;
if (!productTab.parent_browser_context) {
console.log(
`⏳ Waiting for parent process... (Product ID: ${productTab.id})`
);
return;
}
}
// Kết nối Puppeteer nếu chưa có page_context
if (!productTab.page_context) {
console.log(
`🔌 Connecting to page for Product ID: ${productTab.id}`
); );
await productTab.puppeteer_connect();
}
Promise.allSettled( // Kiểm tra URL và điều hướng nếu cần
productTabs.map(async (productTab) => { if ((await productTab.page_context.url()) !== productTab.url) {
console.log(`📌 Processing Product ID: ${productTab.id}`); console.log(
`🔄 Redirecting to new URL for Product ID: ${productTab.id}`
// Xác định parent context
if (!productTab.parent_browser_context) {
const parent = _.find(MANAGER_BIDS, { id: productTab.web_bid.id });
productTab.parent_browser_context = parent?.browser_context;
if (!productTab.parent_browser_context) {
console.log(`⏳ Waiting for parent process... (Product ID: ${productTab.id})`);
return;
}
}
// Kết nối Puppeteer nếu chưa có page_context
if (!productTab.page_context) {
console.log(`🔌 Connecting to page for Product ID: ${productTab.id}`);
await productTab.puppeteer_connect();
}
// Kiểm tra URL và điều hướng nếu cần
if ((await productTab.page_context.url()) !== productTab.url) {
console.log(`🔄 Redirecting to new URL for Product ID: ${productTab.id}`);
await productTab.gotoLink();
}
// Cập nhật nếu cần thiết
if (shouldUpdateProductTab(productTab)) {
console.log(`🔄 Updating Product ID: ${productTab.id}...`);
await productTab.update();
} else {
console.log(`⏳ Product ID: ${productTab.id} was updated recently. Skipping update.`);
}
// Chờ first bid
if (!productTab.first_bid) {
console.log(`🎯 Waiting for first bid for Product ID: ${productTab.id}`);
return;
}
// Kiểm tra thời gian bid
if (productTab.start_bid_time && !isTimeReached(productTab.start_bid_time)) {
console.log(`⏳ Not yet time to bid. Skipping Product ID: ${productTab.id}`);
return;
}
// Thực thi hành động
console.log(`🚀 Executing action for Product ID: ${productTab.id}`);
await productTab.action();
}),
); );
await productTab.gotoLink();
}
// Dọn dẹp tab không dùng // Cập nhật nếu cần thiết
console.log('🧹 Cleaning up unused tabs...'); if (shouldUpdateProductTab(productTab)) {
clearLazyTab(); console.log(`🔄 Updating Product ID: ${productTab.id}...`);
await productTab.update();
} else {
console.log(
`⏳ Product ID: ${productTab.id} was updated recently. Skipping update.`
);
}
// Cập nhật trạng thái tracking // Chờ first bid
console.log('📊 Tracking work status...'); if (!productTab.first_bid) {
workTracking(); console.log(
} catch (error) { `🎯 Waiting for first bid for Product ID: ${productTab.id}`
console.error('❌ Error in tracking loop:', error); );
} return;
}
console.log(`⏳ Waiting ${configs.AUTO_TRACKING_DELAY / 1000} seconds before the next iteration...`); // Kiểm tra thời gian bid
await delay(configs.AUTO_TRACKING_DELAY); if (
productTab.start_bid_time &&
!isTimeReached(productTab.start_bid_time)
) {
console.log(
`⏳ Not yet time to bid. Skipping Product ID: ${productTab.id}`
);
return;
}
// Thực thi hành động
console.log(`🚀 Executing action for Product ID: ${productTab.id}`);
await productTab.action();
})
);
// Dọn dẹp tab không dùng
console.log("🧹 Cleaning up unused tabs...");
clearLazyTab();
// Cập nhật trạng thái tracking
console.log("📊 Tracking work status...");
workTracking();
// Bắn event status login
console.log("📊 Tracking login status...");
trackingLoginStatus();
} catch (error) {
console.error("❌ Error in tracking loop:", error);
} }
console.log(
`⏳ Waiting ${
configs.AUTO_TRACKING_DELAY / 1000
} seconds before the next iteration...`
);
await delay(configs.AUTO_TRACKING_DELAY);
}
}; };
const clearLazyTab = async () => { const clearLazyTab = async () => {
if (!global.IS_CLEANING) { if (!global.IS_CLEANING) {
console.log('🚀 Cleaning flag is OFF. Proceeding with operation.'); console.log("🚀 Cleaning flag is OFF. Proceeding with operation.");
return; return;
} }
if (!browser) { if (!browser) {
console.warn('⚠️ Browser is not available or disconnected.'); console.warn("⚠️ Browser is not available or disconnected.");
return; return;
} }
try { 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( console.log(
'🔍 Page URLs:', "🔍 Page URLs:",
pages.map((page) => page.url()), 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 // 🔥 Bỏ qua tab 'about:blank' hoặc tab không có URL
if (!pageUrl || pageUrl === 'about:blank') continue; if (!pageUrl || pageUrl === "about:blank") continue;
if (!activeUrls.includes(pageUrl)) { if (!activeUrls.includes(pageUrl)) {
if (!page.isClosed() && browser.isConnected()) { if (!page.isClosed() && browser.isConnected()) {
try { try {
const bidData = MANAGER_BIDS.filter((item) => item.page_context) const bidData = MANAGER_BIDS.filter((item) => item.page_context)
.map((i) => ({ .map((i) => ({
current_url: i.page_context.url(), current_url: i.page_context.url(),
data: i, data: i,
})) }))
.find((j) => j.current_url === pageUrl); .find((j) => j.current_url === pageUrl);
console.log(bidData); console.log(bidData);
if (bidData && bidData.data) { if (bidData && bidData.data) {
await safeClosePage(bidData.data); await safeClosePage(bidData.data);
} else { } else {
await page.close(); await page.close();
}
console.log(`🛑 Closing unused tab: ${pageUrl}`);
} catch (err) {
console.warn(`⚠️ Error closing tab ${pageUrl}:, err.message`);
}
}
} }
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);
} }
} catch (err) {
console.error("❌ Error in clearLazyTab:", err.message);
}
}; };
const workTracking = async () => { const workTracking = async () => {
try { try {
const activeData = _.flatMap(MANAGER_BIDS, (item) => [item, ...item.children]); const activeData = _.flatMap(MANAGER_BIDS, (item) => [
const limit = pLimit(5); item,
...item.children,
]);
const limit = pLimit(5);
await Promise.allSettled( await Promise.allSettled(
activeData activeData
.filter((item) => item.page_context && !item.page_context.isClosed()) .filter((item) => item.page_context && !item.page_context.isClosed())
.filter((item) => !activeTasks.has(item.id)) .filter((item) => !activeTasks.has(item.id))
.map((item) => .map((item) =>
limit(async () => { limit(async () => {
activeTasks.add(item.id); activeTasks.add(item.id);
try { try {
await item.handleTakeWorkSnapshot(); await item.handleTakeWorkSnapshot();
} catch (error) { } catch (error) {
console.error(`[❌ ERROR] Snapshot failed for Product ID: ${item.id}`, error); console.error(
} finally { `[❌ ERROR] Snapshot failed for Product ID: ${item.id}`,
activeTasks.delete(item.id); error
} );
}), } finally {
), activeTasks.delete(item.id);
); }
} catch (error) { })
console.error(`[❌ ERROR] Work tracking failed: ${error.message}\n`, error.stack); )
);
} catch (error) {
console.error(
`[❌ ERROR] Work tracking failed: ${error.message}\n`,
error.stack
);
}
};
const trackingLoginStatus = async () => {
try {
if (!MANAGER_BIDS?.length) return;
const results = await Promise.allSettled(
MANAGER_BIDS.map(async (item) => {
try {
const login_status = await item.isLogin();
await updateLoginStatus({
data: {
id: item.id,
type: item.type,
origin_url: item.origin_url,
},
login_status,
});
} catch (err) {
console.warn(
`[⚠️ WARN] Failed to check login for bid ${
item?.id || "unknown"
}: ${err.message}`
);
}
})
);
// Optional: log summary
const failed = results.filter((r) => r.status === "rejected").length;
if (failed) {
console.warn(`[⚠️ WARN] ${failed} login status checks failed.`);
} }
} catch (error) {
console.error(
`[❌ ERROR] Login status tracking failed: ${error.message}\n`,
error.stack
);
}
}; };
(async () => { (async () => {
const socket = io(`${configs.SOCKET_URL}/bid-ws`, { const socket = io(`${configs.SOCKET_URL}/bid-ws`, {
transports: ['websocket'], transports: ["websocket"],
reconnection: true, reconnection: true,
extraHeaders: { extraHeaders: {
Authorization: process.env.CLIENT_KEY, Authorization: process.env.CLIENT_KEY,
}, },
}); });
// set socket on global app // set socket on global app
global.socket = socket; global.socket = socket;
// listen connect // listen connect
socket.on('connect', () => { socket.on("connect", () => {
console.log('✅ Connected to WebSocket server'); console.log("✅ Connected to WebSocket server");
console.log('🔗 Socket ID:', socket.id); console.log("🔗 Socket ID:", socket.id);
}); });
// listen connect // listen connect
socket.on('disconnect', () => { socket.on("disconnect", () => {
console.log('❌Client key is valid. Disconnected'); console.log("❌Client key is valid. Disconnected");
}); });
// listen event // listen event
socket.on('bidsUpdated', async (data) => { socket.on("bidsUpdated", async (data) => {
console.log('📢 Bids Data:', data); console.log("📢 Bids Data:", data);
handleUpdateProductTabs(data); handleUpdateProductTabs(data);
}); });
socket.on('webUpdated', async (data) => { socket.on("webUpdated", async (data) => {
console.log('📢 Account was updated:', data); console.log("📢 Account was updated:", data);
const isDeleted = deleteProfile(data); const isDeleted = deleteProfile(data);
if (isDeleted) { if (isDeleted) {
console.log('✅ Profile deleted successfully!'); console.log("✅ Profile deleted successfully!");
const tab = MANAGER_BIDS.find((item) => item.url === data.url); const tab = MANAGER_BIDS.find((item) => item.url === data.url);
if (!tab) return; if (!tab) return;
global.IS_CLEANING = false; global.IS_CLEANING = false;
await Promise.all(tab.children.map((tab) => safeClosePage(tab))); await Promise.all(tab.children.map((tab) => safeClosePage(tab)));
await safeClosePage(tab); await safeClosePage(tab);
global.IS_CLEANING = true; global.IS_CLEANING = true;
} else { } else {
console.log('⚠️ No profile found to delete.'); console.log("⚠️ No profile found to delete.");
} }
}); });
// AUTO TRACKING // AUTO TRACKING
tracking(); tracking();
})(); })();

View File

@ -1,30 +1,44 @@
import BID_TYPE from '../system/bid-type.js'; import BID_TYPE from "../system/bid-type.js";
import CONSTANTS from '../system/constants.js'; import CONSTANTS from "../system/constants.js";
import { takeSnapshot } from '../system/utils.js'; import { takeSnapshot } from "../system/utils.js";
import _ from 'lodash'; import _ from "lodash";
export class Bid { export class Bid {
type; type;
puppeteer_connect; puppeteer_connect;
url; url;
action; action;
page_context; page_context;
constructor(type, url, puppeteer_connect) { constructor(type, url, puppeteer_connect) {
this.type = type; this.type = type;
this.url = url; this.url = url;
this.puppeteer_connect = puppeteer_connect; this.puppeteer_connect = puppeteer_connect;
}
handleTakeWorkSnapshot = _.debounce(async () => {
if (!this.page_context) return;
try {
// await this.page_context.waitForSelector('#pageContainer', { timeout: 10000 });
console.log(
`✅ Page fully loaded. Taking snapshot for ${
this.type === BID_TYPE.PRODUCT_TAB ? "Product ID" : "Tracking ID"
}: ${this.id}`
);
takeSnapshot(
this.page_context,
this,
"working",
CONSTANTS.TYPE_IMAGE.WORK
);
} catch (error) {
console.error(
`❌ Error taking snapshot for Product ID: ${this.id}:`,
error.message
);
} }
}, 1000);
handleTakeWorkSnapshot = _.debounce(async () => { async isLogin() {}
if (!this.page_context) return;
try {
// await this.page_context.waitForSelector('#pageContainer', { timeout: 10000 });
console.log(`✅ Page fully loaded. Taking snapshot for ${this.type === BID_TYPE.PRODUCT_TAB ? 'Product ID' : 'Tracking ID'}: ${this.id}`);
takeSnapshot(this.page_context, this, 'working', CONSTANTS.TYPE_IMAGE.WORK);
} catch (error) {
console.error(`❌ Error taking snapshot for Product ID: ${this.id}:`, error.message);
}
}, 1000);
} }

View File

@ -1,226 +1,292 @@
import path from 'path'; 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 { delay, extractNumber, getPathProfile, isTimeReached, safeClosePage } from '../../system/utils.js'; import {
import { ApiBid } from '../api-bid.js'; delay,
import fs from 'fs'; extractNumber,
getPathProfile,
isTimeReached,
safeClosePage,
} from "../../system/utils.js";
import { ApiBid } from "../api-bid.js";
import fs from "fs";
export class GrayApiBid extends ApiBid { export class GrayApiBid extends ApiBid {
retry_login = 0; retry_login = 0;
retry_login_count = 3; retry_login_count = 3;
constructor({ ...prev }) { constructor({ ...prev }) {
super(prev); super(prev);
} }
async polling(page) { async polling(page) {
try { try {
// // 🔥 Xóa tất cả event chặn request trước khi thêm mới // // 🔥 Xóa tất cả event chặn request trước khi thêm mới
// page.removeAllListeners('request'); // page.removeAllListeners('request');
// await page.setRequestInterception(true); // await page.setRequestInterception(true);
// page.on('request', (request) => { // page.on('request', (request) => {
// if (request.url().includes('api/Notifications/GetOutBidLots')) { // if (request.url().includes('api/Notifications/GetOutBidLots')) {
// console.log('🚀 Fake response cho request:', request.url()); // console.log('🚀 Fake response cho request:', request.url());
// const fakeData = fs.readFileSync('./data/fake-out-lots.json', 'utf8'); // const fakeData = fs.readFileSync('./data/fake-out-lots.json', 'utf8');
// request.respond({ // request.respond({
// status: 200, // status: 200,
// contentType: 'application/json', // contentType: 'application/json',
// body: fakeData, // body: fakeData,
// }); // });
// } else { // } else {
// try { // try {
// request.continue(); // ⚠️ Chỉ tiếp tục nếu request chưa bị chặn // request.continue(); // ⚠️ Chỉ tiếp tục nếu request chưa bị chặn
// } catch (error) { // } catch (error) {
// console.error('⚠️ Lỗi khi tiếp tục request:', error.message); // console.error('⚠️ Lỗi khi tiếp tục request:', error.message);
// } // }
// } // }
// }); // });
console.log(`🔄 [${this.id}] Starting polling process...`); console.log(`🔄 [${this.id}] Starting polling process...`);
await page.evaluateHandle( await page.evaluateHandle(
(apiUrl, interval, bidId) => { (apiUrl, interval, bidId) => {
if (window._autoBidPollingStarted) { if (window._autoBidPollingStarted) {
console.log(`✅ [${bidId}] Polling is already running. Skipping initialization.`); console.log(
return; `✅ [${bidId}] Polling is already running. Skipping initialization.`
}
console.log(`🚀 [${bidId}] Initializing polling...`);
window._autoBidPollingStarted = true;
function sendRequest() {
console.log(`📡 [${bidId}] Sending request to track out-bid lots...`);
fetch(apiUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: JSON.stringify({ timeStamp: new Date().getTime() }),
})
.then((response) => console.log(`✅ [${bidId}] Response received: ${response.status}`))
.catch((err) => console.error(`⚠️ [${bidId}] Request error:`, err));
}
window._pollingInterval = setInterval(sendRequest, interval);
},
configs.WEB_CONFIGS.GRAYS.API_CALL_TO_TRACKING,
configs.WEB_CONFIGS.GRAYS.AUTO_CALL_API_TO_TRACKING,
this.id,
); );
} catch (error) {
if (error.message.includes('Execution context was destroyed')) {
console.log(`⚠️ [${this.id}] Page reload detected, restarting polling...`);
await page.waitForNavigation({ waitUntil: 'networkidle2' }).catch(() => {});
return await this.polling(page);
}
console.error(`🚨 [${this.id}] Unexpected polling error:`, error);
throw error;
}
}
async handleCreateLogsOnServer(data) {
if (!Array.isArray(data)) return;
const values = data.map((item) => {
return {
model: item.Sku,
lot_id: item.Id,
out_price: extractNumber(item.Bid) || 0,
raw_data: JSON.stringify(item),
};
});
await createOutBidLog(values);
}
listen_out_bids = async (data) => {
if (this.children.length <= 0 || data.length <= 0) return;
// SAVE LOGS ON SERVER
this.handleCreateLogsOnServer(data);
const bidOutLots = data.filter((bid) => !this.children_processing.some((item) => item.model === bid.Sku));
const handleChildren = this.children.filter((item) => bidOutLots.some((i) => i.Sku === item.model));
console.log({ handleChildren, children_processing: this.children_processing, data, bidOutLots });
for (const product_tab of handleChildren) {
if (!isTimeReached(product_tab.start_bid_time)) {
console.log(`❌ [${this.id}] It's not time yet ID: ${product_tab.id} continue waiting...`);
return;
}
this.children_processing.push(product_tab);
if (!product_tab.page_context) {
await product_tab.puppeteer_connect();
}
await product_tab.action();
this.children_processing = this.children_processing.filter((item) => item.id !== product_tab.id);
}
};
async handleLogin() {
const page = this.page_context;
global.IS_CLEANING = false;
const filePath = getPathProfile(this.origin_url);
// 🔍 Check if already logged in (login input should not be visible)
if (!(await page.$('input[name="username"]')) || fs.existsSync(filePath)) {
console.log(`✅ [${this.id}] Already logged in, skipping login.`);
global.IS_CLEANING = true;
this.retry_login = 0; // Reset retry count
return; return;
} }
console.log(`🔑 [${this.id}] Starting login process...`); console.log(`🚀 [${bidId}] Initializing polling...`);
window._autoBidPollingStarted = true;
try { function sendRequest() {
await page.type('input[name="username"]', this.username, { delay: 100 }); console.log(
await page.type('input[name="password"]', this.password, { delay: 150 }); `📡 [${bidId}] Sending request to track out-bid lots...`
await page.click('#loginButton'); );
fetch(apiUrl, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: JSON.stringify({ timeStamp: new Date().getTime() }),
})
.then((response) =>
console.log(
`✅ [${bidId}] Response received: ${response.status}`
)
)
.catch((err) =>
console.error(`⚠️ [${bidId}] Request error:`, err)
);
}
await Promise.race([ window._pollingInterval = setInterval(sendRequest, interval);
page.waitForNavigation({ timeout: 8000, waitUntil: 'domcontentloaded' }), },
page.waitForFunction(() => !document.querySelector('input[name="username"]'), { timeout: 8000 }), // Check if login input disappears configs.WEB_CONFIGS.GRAYS.API_CALL_TO_TRACKING,
]); configs.WEB_CONFIGS.GRAYS.AUTO_CALL_API_TO_TRACKING,
this.id
);
} catch (error) {
if (error.message.includes("Execution context was destroyed")) {
console.log(
`⚠️ [${this.id}] Page reload detected, restarting polling...`
);
await page
.waitForNavigation({ waitUntil: "networkidle2" })
.catch(() => {});
return await this.polling(page);
}
if (!(await page.$('input[name="username"]'))) { console.error(`🚨 [${this.id}] Unexpected polling error:`, error);
console.log(`✅ [${this.id}] Login successful!`); throw error;
this.retry_login = 0; // Reset retry count after success }
return; }
}
throw new Error('Login failed, login input is still visible.'); async handleCreateLogsOnServer(data) {
} catch (error) { if (!Array.isArray(data)) return;
console.log(`⚠️ [${this.id}] Login error: ${error.message}. Retrying attempt ${this.retry_login + 1}`);
this.retry_login++; const values = data.map((item) => {
if (this.retry_login > this.retry_login_count) { return {
console.log(`🚨 [${this.id}] Maximum login attempts reached. Stopping login process.`); model: item.Sku,
safeClosePage(this); lot_id: item.Id,
this.retry_login = 0; // Reset retry count out_price: extractNumber(item.Bid) || 0,
return; raw_data: JSON.stringify(item),
} };
});
safeClosePage(this); // Close the current page await createOutBidLog(values);
await delay(1000); }
if (!this.page_context) { listen_out_bids = async (data) => {
await this.puppeteer_connect(); // Reconnect if page is closed if (this.children.length <= 0 || data.length <= 0) return;
}
return await this.action(); // Retry login // SAVE LOGS ON SERVER
} finally { this.handleCreateLogsOnServer(data);
global.IS_CLEANING = true;
} const bidOutLots = data.filter(
(bid) => !this.children_processing.some((item) => item.model === bid.Sku)
);
const handleChildren = this.children.filter((item) =>
bidOutLots.some((i) => i.Sku === item.model)
);
console.log({
handleChildren,
children_processing: this.children_processing,
data,
bidOutLots,
});
for (const product_tab of handleChildren) {
if (!isTimeReached(product_tab.start_bid_time)) {
console.log(
`❌ [${this.id}] It's not time yet ID: ${product_tab.id} continue waiting...`
);
return;
}
this.children_processing.push(product_tab);
if (!product_tab.page_context) {
await product_tab.puppeteer_connect();
}
await product_tab.action();
this.children_processing = this.children_processing.filter(
(item) => item.id !== product_tab.id
);
}
};
isLogin = async () => {
if (!this.page_context) return false;
const filePath = getPathProfile(this.origin_url);
if (
!(await this.page_context.$('input[name="username"]')) ||
fs.existsSync(filePath)
) {
return true;
} }
action = async () => { return false;
try { };
const page = this.page_context;
await page.goto(this.url, { waitUntil: 'networkidle2' }); async handleLogin() {
console.log(`🌍 [${this.id}] Navigated to URL: ${this.url}`); const page = this.page_context;
await page.bringToFront(); global.IS_CLEANING = false;
console.log(`🎯 [${this.id}] Brought page to front.`);
// Set userAgent const filePath = getPathProfile(this.origin_url);
await page.setUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36');
console.log(`🛠️ [${this.id}] UserAgent set.`);
page.on('response', async (response) => { // 🔍 Check if already logged in (login input should not be visible)
if (response.request().url().includes('api/Notifications/GetOutBidLots')) { if (!(await page.$('input[name="username"]')) || fs.existsSync(filePath)) {
console.log(`🚀 [${this.id}] API POST detected: ${response.url()}`); console.log(`✅ [${this.id}] Already logged in, skipping login.`);
try { global.IS_CLEANING = true;
const responseBody = await response.json(); this.retry_login = 0; // Reset retry count
await this.listen_out_bids(responseBody.AuctionOutBidLots || []); return;
} catch (error) { }
console.error(`❌ [${this.id}] Error processing response:`, error?.message);
}
}
});
page.on('load', async () => { console.log(`🔑 [${this.id}] Starting login process...`);
console.log(`🔄 [${this.id}] Page has reloaded, restarting polling...`);
await this.polling(page);
await this.handleLogin();
});
await this.polling(page); // Call when first load try {
await this.handleLogin(); await page.type('input[name="username"]', this.username, { delay: 100 });
} catch (error) { await page.type('input[name="password"]', this.password, { delay: 150 });
console.log(`❌ [${this.id}] Action error: ${error.message}`); await page.click("#loginButton");
await Promise.race([
page.waitForNavigation({
timeout: 8000,
waitUntil: "domcontentloaded",
}),
page.waitForFunction(
() => !document.querySelector('input[name="username"]'),
{ timeout: 8000 }
), // Check if login input disappears
]);
if (!(await page.$('input[name="username"]'))) {
console.log(`✅ [${this.id}] Login successful!`);
this.retry_login = 0; // Reset retry count after success
return;
}
throw new Error("Login failed, login input is still visible.");
} catch (error) {
console.log(
`⚠️ [${this.id}] Login error: ${error.message}. Retrying attempt ${
this.retry_login + 1
} `
);
this.retry_login++;
if (this.retry_login > this.retry_login_count) {
console.log(
`🚨 [${this.id}] Maximum login attempts reached. Stopping login process.`
);
safeClosePage(this);
this.retry_login = 0; // Reset retry count
return;
}
safeClosePage(this); // Close the current page
await delay(1000);
if (!this.page_context) {
await this.puppeteer_connect(); // Reconnect if page is closed
}
return await this.action(); // Retry login
} finally {
global.IS_CLEANING = true;
}
}
action = async () => {
try {
const page = this.page_context;
await page.goto(this.url, { waitUntil: "networkidle2" });
console.log(`🌍 [${this.id}] Navigated to URL: ${this.url}`);
await page.bringToFront();
console.log(`🎯 [${this.id}] Brought page to front.`);
// Set userAgent
await page.setUserAgent(
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
);
console.log(`🛠️ [${this.id}] UserAgent set.`);
page.on("response", async (response) => {
if (
response.request().url().includes("api/Notifications/GetOutBidLots")
) {
console.log(`🚀 [${this.id}] API POST detected: ${response.url()}`);
try {
const responseBody = await response.json();
await this.listen_out_bids(responseBody.AuctionOutBidLots || []);
} catch (error) {
console.error(
`❌ [${this.id}] Error processing response:`,
error?.message
);
}
} }
}; });
page.on("load", async () => {
console.log(`🔄 [${this.id}] Page has reloaded, restarting polling...`);
await this.polling(page);
await this.handleLogin();
});
await this.polling(page); // Call when first load
await this.handleLogin();
} catch (error) {
console.log(`❌ [${this.id}] Action error: ${error.message}`);
}
};
} }

View File

@ -1,245 +1,322 @@
import { outBid, pushPrice, updateBid, updateStatusByPrice } from '../../system/apis/bid.js'; import {
import CONSTANTS from '../../system/constants.js'; outBid,
import { delay, extractNumber, isNumber, isTimeReached, removeFalsyValues, safeClosePage, takeSnapshot } from '../../system/utils.js'; pushPrice,
import { ProductBid } from '../product-bid.js'; updateBid,
updateStatusByPrice,
} from "../../system/apis/bid.js";
import CONSTANTS from "../../system/constants.js";
import {
delay,
extractNumber,
isNumber,
isTimeReached,
removeFalsyValues,
safeClosePage,
takeSnapshot,
} from "../../system/utils.js";
import { ProductBid } from "../product-bid.js";
export class GraysProductBid extends ProductBid { export class GraysProductBid extends ProductBid {
constructor({ ...prev }) { constructor({ ...prev }) {
super(prev); super(prev);
}
async validate({ page, price_value }) {
if (!this.start_bid_time || !isTimeReached(this.start_bid_time)) {
console.log(`❌ [${this.id}] It's not time yet`);
return { result: false, bid_price: 0 };
} }
async validate({ page, price_value }) { if (!isNumber(price_value)) {
if (!this.start_bid_time || !isTimeReached(this.start_bid_time)) { console.log(`❌ [${this.id}] Can't get PRICE_VALUE`);
console.log(`❌ [${this.id}] It's not time yet`); await takeSnapshot(page, this, "price-value-null");
return { result: false, bid_price: 0 };
}
if (!isNumber(price_value)) { return { result: false, bid_price: 0 };
console.log(`❌ [${this.id}] Can't get PRICE_VALUE`);
await takeSnapshot(page, this, 'price-value-null');
return { result: false, bid_price: 0 };
}
const bid_price = this.plus_price + Number(price_value);
if (bid_price > this.max_price) {
console.log(`${this.id} PRICE BID is more than MAX_VALUE => STOP BID THIS PRODUCT`);
await takeSnapshot(page, this, 'price-bid-more-than');
await outBid(this.id);
return { result: false, bid_price: 0 };
}
const response = await pushPrice({
bid_id: this.id,
price: bid_price,
});
if (!response.status) {
return { result: false, bid_price: 0 };
}
this.histories = response.data;
// RESET first bid
if (this.histories.length > 0 && this.first_bid) {
this.first_bid = false;
}
return { result: true, bid_price };
} }
getCloseTime = async () => { const bid_price = this.plus_price + Number(price_value);
try {
if (!this.page_context) return null;
await this.page_context.waitForSelector('#lot-closing-datetime', { timeout: 3000 }); if (bid_price > this.max_price) {
console.log(
`${this.id} PRICE BID is more than MAX_VALUE => STOP BID THIS PRODUCT`
);
await takeSnapshot(page, this, "price-bid-more-than");
return await this.page_context.$eval('#lot-closing-datetime', (el) => el.value); await outBid(this.id);
} catch (error) {
return null;
}
};
getPriceWasBid = async () => { return { result: false, bid_price: 0 };
try {
if (!this.page_context) return null;
await this.page_context.waitForSelector('#biddableLot form div div:nth-child(1) span span', { timeout: 3000 });
const element = await this.page_context.$('#biddableLot form div div:nth-child(1) span span');
const textPrice = await this.page_context.evaluate((el) => el.textContent, element);
return extractNumber(textPrice) || null;
} catch (error) {
return null;
}
};
async isCloseProduct() {
const close_time = await this.getCloseTime();
if (!close_time) {
const priceWasBid = await this.getPriceWasBid();
await updateStatusByPrice(this.id, priceWasBid);
return { result: true, close_time: null };
}
await delay(500);
if (!close_time || new Date(close_time).getTime() <= new Date().getTime()) {
console.log(`❌ [${this.id}] Product is close ${close_time}`);
return { result: true, close_time };
}
return { result: false, close_time };
} }
async handleWritePrice(page, bid_price) { const response = await pushPrice({
await page.type('#price', String(bid_price)); bid_id: this.id,
await delay(500); price: bid_price,
});
if (!response.status) {
return { result: false, bid_price: 0 };
} }
async placeBid(page) { this.histories = response.data;
try {
await page.click('#bid-type-standard');
await delay(500);
await page.click('#btnSubmit'); // RESET first bid
await delay(1000); if (this.histories.length > 0 && this.first_bid) {
this.first_bid = false;
await page.waitForSelector('button', { timeout: 5000 });
await delay(500);
await page.click('button');
await page.waitForNavigation({ timeout: 5000 });
await takeSnapshot(page, this, 'bid-success', CONSTANTS.TYPE_IMAGE.SUCCESS);
return true;
} catch (error) {
console.log(`❌ [${this.id}] Timeout to loading`);
await takeSnapshot(page, this, 'timeout to loading');
return false;
}
} }
async handleReturnProductPage(page) { return { result: true, bid_price };
await page.goto(this.url); }
await delay(1000);
getCloseTime = async () => {
try {
if (!this.page_context) return null;
await this.page_context.waitForSelector("#lot-closing-datetime", {
timeout: 3000,
});
return await this.page_context.$eval(
"#lot-closing-datetime",
(el) => el.value
);
} catch (error) {
return null;
}
};
getPriceWasBid = async () => {
try {
if (!this.page_context) return null;
await this.page_context.waitForSelector(
"#biddableLot form div div:nth-child(1) span span",
{ timeout: 3000 }
);
const element = await this.page_context.$(
"#biddableLot form div div:nth-child(1) span span"
);
const textPrice = await this.page_context.evaluate(
(el) => el.textContent,
element
);
return extractNumber(textPrice) || null;
} catch (error) {
return null;
}
};
async isCloseProduct() {
const close_time = await this.getCloseTime();
if (!close_time) {
const priceWasBid = await this.getPriceWasBid();
await updateStatusByPrice(this.id, priceWasBid);
return { result: true, close_time: null };
} }
async handleUpdateBid({ lot_id, close_time, name, current_price, reserve_price }) { await delay(500);
const response = await updateBid(this.id, { lot_id, close_time, name, current_price, reserve_price: Number(reserve_price) || 0 });
if (response) { if (!close_time || new Date(close_time).getTime() <= new Date().getTime()) {
this.lot_id = response.lot_id; console.log(`❌ [${this.id}] Product is close ${close_time}`);
this.close_time = response.close_time; return { result: true, close_time };
this.start_bid_time = response.start_bid_time;
}
} }
update = async () => { return { result: false, close_time };
if (!this.page_context) return; }
const page = this.page_context; async handleWritePrice(page, bid_price) {
await page.type("#price", String(bid_price));
await delay(500);
}
try { async placeBid(page) {
const close_time = await this.getCloseTime(); try {
await page.click("#bid-type-standard");
await delay(500);
// Chờ phần tử xuất hiện trước khi lấy giá trị await page.click("#btnSubmit");
await page.waitForSelector('#priceValue', { timeout: 5000 }).catch(() => null); await delay(1000);
const price_value = await page.$eval('#priceValue', (el) => el.value).catch(() => null);
await page.waitForSelector('#lotId', { timeout: 5000 }).catch(() => null); await page.waitForSelector("button", { timeout: 5000 });
const lot_id = await page.$eval('#lotId', (el) => el.value).catch(() => null);
await page.waitForSelector('#placebid-sticky > div:nth-child(2) > div > h3', { timeout: 5000 }).catch(() => null); await delay(500);
const name = await page.$eval('#placebid-sticky > div:nth-child(2) > div > h3', (el) => el.innerText).catch(() => null);
await page await page.click("button");
.waitForSelector('#biddableLot > form > div > div:nth-child(1) > div:nth-child(1) > div:nth-child(2) > div > span > span', { timeout: 5000 })
.catch(() => null);
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)
.catch(() => null);
console.log(`📌 [${this.id}] Product Info: Lot ID: ${lot_id}, Name: ${name}, Current Price: ${current_price}, Reserve price: ${price_value}`); await page.waitForNavigation({ timeout: 5000 });
const data = removeFalsyValues( await takeSnapshot(
{ page,
lot_id, this,
reserve_price: price_value, "bid-success",
close_time: close_time ? String(close_time) : null, CONSTANTS.TYPE_IMAGE.SUCCESS
name, );
current_price: current_price ? extractNumber(current_price) : null, return true;
}, } catch (error) {
['close_time'], console.log(`❌ [${this.id}] Timeout to loading`);
); await takeSnapshot(page, this, "timeout to loading");
return false;
}
}
this.handleUpdateBid(data); async handleReturnProductPage(page) {
await page.goto(this.url);
await delay(1000);
}
return { price_value, lot_id, name, current_price }; async handleUpdateBid({
} catch (error) { lot_id,
console.error(`🚨 Error updating product info: ${error.message}`); close_time,
return null; name,
} current_price,
}; reserve_price,
}) {
const response = await updateBid(this.id, {
lot_id,
close_time,
name,
current_price,
reserve_price: Number(reserve_price) || 0,
});
action = async () => { if (response) {
try { this.lot_id = response.lot_id;
const page = this.page_context; this.close_time = response.close_time;
this.start_bid_time = response.start_bid_time;
}
}
await this.gotoLink(); update = async () => {
console.log(`🌍 [${this.id}] Navigated to link.`); if (!this.page_context) return;
await delay(1000); const page = this.page_context;
const { close_time, ...isCloseProduct } = await this.isCloseProduct(); try {
if (isCloseProduct.result) { const close_time = await this.getCloseTime();
console.log(`❌ [${this.id}] The product is closed, cannot place a bid.`);
return;
}
await delay(500); // Chờ phần tử xuất hiện trước khi lấy giá trị
await page
.waitForSelector("#priceValue", { timeout: 5000 })
.catch(() => null);
const price_value = await page
.$eval("#priceValue", (el) => el.value)
.catch(() => null);
const { price_value } = await this.update(); await page.waitForSelector("#lotId", { timeout: 5000 }).catch(() => null);
if (!price_value) return; const lot_id = await page
.$eval("#lotId", (el) => el.value)
.catch(() => null);
const { result, bid_price } = await this.validate({ page, price_value }); await page
if (!result) { .waitForSelector("#placebid-sticky > div:nth-child(2) > div > h3", {
console.log(`❌ [${this.id}] Validation failed. Unable to proceed with bidding.`); timeout: 5000,
return; })
} .catch(() => null);
const name = await page
.$eval(".dls-heading-3.lotPageTitle", (el) => el.innerText)
.catch(() => null);
const bidHistoriesItem = _.maxBy(this.histories, 'price'); await page
if (bidHistoriesItem && bidHistoriesItem.price === this.current_price) { .waitForSelector(
console.log(`🔄 [${this.id}] You have already bid on this item! (Bid Price: ${bidHistoriesItem.price})`); "#biddableLot > form > div > div:nth-child(1) > div:nth-child(1) > div:nth-child(2) > div > span > span",
return; { timeout: 5000 }
} )
.catch(() => null);
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
)
.catch(() => null);
if (price_value != bid_price) { console.log(
console.log(`✍️ [${this.id}] Updating bid price from ${price_value}${bid_price}`); `📌 [${this.id}] Product Info: Lot ID: ${lot_id}, Name: ${name}, Current Price: ${current_price}, Reserve price: ${price_value}`
await this.handleWritePrice(page, bid_price); );
}
console.log(`🚀 [${this.id}] Placing the bid...`); const data = removeFalsyValues(
const resultPlaceBid = await this.placeBid(page); {
if (!resultPlaceBid) { lot_id,
console.log(`❌ [${this.id}] Error occurred while placing the bid.`); reserve_price: price_value,
await takeSnapshot(page, this, 'place-bid-action'); close_time: close_time ? String(close_time) : null,
return; name,
} current_price: current_price ? extractNumber(current_price) : null,
},
["close_time"]
);
console.log(`✅ [${this.id}] Bid placed successfully! 🏆 Bid Price: ${bid_price}, Closing Time: ${close_time}`); this.handleUpdateBid(data);
await this.handleReturnProductPage(page);
} catch (error) { return { price_value, lot_id, name, current_price };
console.error(`🚨 [${this.id}] Error navigating the page: ${error.message}`); } catch (error) {
} console.error(`🚨 Error updating product info: ${error.message}`);
}; return null;
}
};
action = async () => {
try {
const page = this.page_context;
await this.gotoLink();
console.log(`🌍 [${this.id}] Navigated to link.`);
await delay(1000);
const { close_time, ...isCloseProduct } = await this.isCloseProduct();
if (isCloseProduct.result) {
console.log(
`❌ [${this.id}] The product is closed, cannot place a bid.`
);
return;
}
await delay(500);
const { price_value } = await this.update();
if (!price_value) return;
const { result, bid_price } = await this.validate({ page, price_value });
if (!result) {
console.log(
`❌ [${this.id}] Validation failed. Unable to proceed with bidding.`
);
return;
}
const bidHistoriesItem = _.maxBy(this.histories, "price");
if (bidHistoriesItem && bidHistoriesItem.price === this.current_price) {
console.log(
`🔄 [${this.id}] You have already bid on this item! (Bid Price: ${bidHistoriesItem.price})`
);
return;
}
if (price_value != bid_price) {
console.log(
`✍️ [${this.id}] Updating bid price from ${price_value}${bid_price}`
);
await this.handleWritePrice(page, bid_price);
}
console.log(`🚀 [${this.id}] Placing the bid...`);
const resultPlaceBid = await this.placeBid(page);
if (!resultPlaceBid) {
console.log(`❌ [${this.id}] Error occurred while placing the bid.`);
await takeSnapshot(page, this, "place-bid-action");
return;
}
console.log(
`✅ [${this.id}] Bid placed successfully! 🏆 Bid Price: ${bid_price}, Closing Time: ${close_time}`
);
await this.handleReturnProductPage(page);
} catch (error) {
console.error(
`🚨 [${this.id}] Error navigating the page: ${error.message}`
);
}
};
} }

View File

@ -1,227 +1,284 @@
import fs from 'fs'; import fs from "fs";
import configs from '../../system/config.js'; import configs from "../../system/config.js";
import { getPathProfile, safeClosePage } from '../../system/utils.js'; import { getPathProfile, safeClosePage } from "../../system/utils.js";
import { ApiBid } from '../api-bid.js'; import { ApiBid } from "../api-bid.js";
import _ from 'lodash'; import _ from "lodash";
import { updateStatusByPrice } from '../../system/apis/bid.js'; import { updateStatusByPrice } from "../../system/apis/bid.js";
export class LangtonsApiBid extends ApiBid { export class LangtonsApiBid extends ApiBid {
reloadInterval = null; reloadInterval = null;
constructor({ ...prev }) { constructor({ ...prev }) {
super(prev); super(prev);
}
waitVerifyData = async () =>
new Promise((rev, rej) => {
// Tạo timeout để reject sau 1 phút nếu không có phản hồi
const timeout = setTimeout(() => {
global.socket.off(`verify-code.${this.origin_url}`); // Xóa listener tránh rò rỉ bộ nhớ
rej(
new Error(
`[${this.id}] Timeout: No verification code received within 1 minute.`
)
);
}, 120 * 1000); // 120 giây
global.socket.on(`verify-code.${this.origin_url}`, async (data) => {
console.log(`📢 [${this.id}] VERIFY CODE:`, data);
clearTimeout(timeout); // Hủy timeout vì đã nhận được mã
global.socket.off(`verify-code.${this.origin_url}`); // Xóa listener tránh lặp lại
rev(data); // Resolve với dữ liệu nhận được
});
});
isLogin = async () => {
if (!this.page_context) return false;
const filePath = getPathProfile(this.origin_url);
return (
!(await this.page_context.$('input[name="loginEmail"]')) &&
fs.existsSync(filePath)
);
};
async handleLogin() {
const page = this.page_context;
global.IS_CLEANING = false;
const filePath = getPathProfile(this.origin_url);
await page.waitForNavigation({ waitUntil: "domcontentloaded" });
// 🛠 Check if already logged in (login input should not be visible or profile exists)
if (
!(await page.$('input[name="loginEmail"]')) &&
fs.existsSync(filePath)
) {
console.log(`✅ [${this.id}] Already logged in, skipping login process.`);
return;
} }
waitVerifyData = async () => if (fs.existsSync(filePath)) {
new Promise((rev, rej) => { console.log(`🗑 [${this.id}] Deleting existing file: ${filePath}`);
// Tạo timeout để reject sau 1 phút nếu không có phản hồi fs.unlinkSync(filePath);
const timeout = setTimeout(() => {
global.socket.off(`verify-code.${this.origin_url}`); // Xóa listener tránh rò rỉ bộ nhớ
rej(new Error(`[${this.id}] Timeout: No verification code received within 1 minute.`));
}, 120 * 1000); // 120 giây
global.socket.on(`verify-code.${this.origin_url}`, async (data) => {
console.log(`📢 [${this.id}] VERIFY CODE:`, data);
clearTimeout(timeout); // Hủy timeout vì đã nhận được mã
global.socket.off(`verify-code.${this.origin_url}`); // Xóa listener tránh lặp lại
rev(data); // Resolve với dữ liệu nhận được
});
});
async isLogin() {
if (!this.page_context) return false;
const filePath = getPathProfile(this.origin_url);
return !(await this.page_context.$('input[name="loginEmail"]')) && fs.existsSync(filePath);
} }
async handleLogin() { const children = this.children.filter((item) => item.page_context);
const page = this.page_context; console.log(
`🔍 [${this.id}] Found ${children.length} child pages to close.`
);
global.IS_CLEANING = false; if (children.length > 0) {
console.log(`🛑 [${this.id}] Closing child pages...`);
await Promise.all(
children.map((item) => {
console.log(
`➡ [${this.id}] Closing child page with context: ${item.page_context}`
);
return safeClosePage(item);
})
);
const filePath = getPathProfile(this.origin_url); console.log(
`➡ [${this.id}] Closing main page context: ${this.page_context}`
await page.waitForNavigation({ waitUntil: 'domcontentloaded' }); );
await safeClosePage(this);
// 🛠 Check if already logged in (login input should not be visible or profile exists)
if (!(await page.$('input[name="loginEmail"]')) && fs.existsSync(filePath)) {
console.log(`✅ [${this.id}] Already logged in, skipping login process.`);
return;
}
if (fs.existsSync(filePath)) {
console.log(`🗑 [${this.id}] Deleting existing file: ${filePath}`);
fs.unlinkSync(filePath);
}
const children = this.children.filter((item) => item.page_context);
console.log(`🔍 [${this.id}] Found ${children.length} child pages to close.`);
if (children.length > 0) {
console.log(`🛑 [${this.id}] Closing child pages...`);
await Promise.all(
children.map((item) => {
console.log(`➡ [${this.id}] Closing child page with context: ${item.page_context}`);
return safeClosePage(item);
}),
);
console.log(`➡ [${this.id}] Closing main page context: ${this.page_context}`);
await safeClosePage(this);
}
console.log(`🔑 [${this.id}] Starting login process...`);
try {
// ⌨ Enter email
console.log(`✍ [${this.id}] Entering email:`, this.username);
await page.type('input[name="loginEmail"]', this.username, { delay: 100 });
// ⌨ Enter password
console.log(`✍ [${this.id}] Entering password...`);
await page.type('input[name="loginPassword"]', this.password, { delay: 150 });
// ✅ Click the "Remember Me" checkbox
console.log(`🔘 [${this.id}] Clicking the "Remember Me" checkbox`);
await page.click('#rememberMe', { delay: 80 });
// 🚀 Click the login button
console.log(`🔘 [${this.id}] Clicking the "Login" button`);
await page.click('#loginFormSubmitButton', { delay: 92 });
// ⏳ Wait for navigation after login
console.log(`⏳ [${this.id}] Waiting for navigation after login...`);
await page.waitForNavigation({ timeout: 8000, waitUntil: 'domcontentloaded' });
console.log(`🌍 [${this.id}] Current page after login:`, page.url());
// 📢 Listen for verification code event
console.log(`👂 [${this.id}] Listening for event: verify-code.${this.origin_url}`);
// ⏳ Wait for verification code from socket event
const { name, code } = await this.waitVerifyData();
console.log(`✅ [${this.id}] Verification code received:`, { name, code });
// ⌨ Enter verification code
console.log(`✍ [${this.id}] Entering verification code...`);
await page.type('#code', code, { delay: 120 });
// 🚀 Click the verification confirmation button
console.log(`🔘 [${this.id}] Clicking the verification confirmation button`);
await page.click('.btn.btn-block.btn-primary', { delay: 90 });
// ⏳ Wait for navigation after verification
console.log(`⏳ [${this.id}] Waiting for navigation after verification...`);
await page.waitForNavigation({ timeout: 15000, waitUntil: 'domcontentloaded' });
await page.goto(this.url, { waitUntil: 'networkidle2' });
// 📂 Save session context to avoid re-login
await this.saveContext();
console.log(`✅ [${this.id}] Login successful!`);
// await page.goto(this.url);
console.log(`✅ [${this.id}] Navigation successful!`);
} catch (error) {
console.error(`❌ [${this.id}] Error during login process:`, error.message);
} finally {
global.IS_CLEANING = true;
}
} }
async getWonList() { console.log(`🔑 [${this.id}] Starting login process...`);
try {
await page.waitForSelector('.row.account-product-list', { timeout: 30000 });
const items = await page.evaluate(() => { try {
return Array.from(document.querySelectorAll('.row.account-product-list')).map((item) => item.getAttribute('data-lotid') || null); // ⌨ Enter email
}); console.log(`✍ [${this.id}] Entering email:`, this.username);
await page.type('input[name="loginEmail"]', this.username, {
delay: 100,
});
return _.compact(items); // ⌨ Enter password
} catch (error) { console.log(`✍ [${this.id}] Entering password...`);
return []; await page.type('input[name="loginPassword"]', this.password, {
} delay: 150,
});
// ✅ Click the "Remember Me" checkbox
console.log(`🔘 [${this.id}] Clicking the "Remember Me" checkbox`);
await page.click("#rememberMe", { delay: 80 });
// 🚀 Click the login button
console.log(`🔘 [${this.id}] Clicking the "Login" button`);
await page.click("#loginFormSubmitButton", { delay: 92 });
// ⏳ Wait for navigation after login
console.log(`⏳ [${this.id}] Waiting for navigation after login...`);
await page.waitForNavigation({
timeout: 8000,
waitUntil: "domcontentloaded",
});
console.log(`🌍 [${this.id}] Current page after login:`, page.url());
// 📢 Listen for verification code event
console.log(
`👂 [${this.id}] Listening for event: verify-code.${this.origin_url}`
);
// ⏳ Wait for verification code from socket event
const { name, code } = await this.waitVerifyData();
console.log(`✅ [${this.id}] Verification code received:`, {
name,
code,
});
// ⌨ Enter verification code
console.log(`✍ [${this.id}] Entering verification code...`);
await page.type("#code", code, { delay: 120 });
// 🚀 Click the verification confirmation button
console.log(
`🔘 [${this.id}] Clicking the verification confirmation button`
);
await page.click(".btn.btn-block.btn-primary", { delay: 90 });
// ⏳ Wait for navigation after verification
console.log(
`⏳ [${this.id}] Waiting for navigation after verification...`
);
await page.waitForNavigation({
timeout: 15000,
waitUntil: "domcontentloaded",
});
await page.goto(this.url, { waitUntil: "networkidle2" });
// 📂 Save session context to avoid re-login
await this.saveContext();
console.log(`✅ [${this.id}] Login successful!`);
// await page.goto(this.url);
console.log(`✅ [${this.id}] Navigation successful!`);
} catch (error) {
console.error(
`❌ [${this.id}] Error during login process:`,
error.message
);
} finally {
global.IS_CLEANING = true;
}
}
async getWonList() {
try {
await page.waitForSelector(".row.account-product-list", {
timeout: 30000,
});
const items = await page.evaluate(() => {
return Array.from(
document.querySelectorAll(".row.account-product-list")
).map((item) => item.getAttribute("data-lotid") || null);
});
return _.compact(items);
} catch (error) {
return [];
}
}
async handleUpdateWonItem() {
console.log(`🔄 [${this.id}] Starting to update the won list...`);
// Lấy danh sách các lot_id thắng
const items = await this.getWonList();
console.log(`📌 [${this.id}] List of won lot_ids:`, items);
// Nếu không có item nào, thoát ra
if (items.length === 0) {
console.log(`⚠️ [${this.id}] No items to update.`);
return;
} }
async handleUpdateWonItem() { // Lọc danh sách `this.children` chỉ giữ lại những item có trong danh sách thắng
console.log(`🔄 [${this.id}] Starting to update the won list...`); const result = _.filter(this.children, (item) =>
_.includes(items, item.lot_id)
);
console.log(
`✅ [${this.id}] ${result.length} items need to be updated:`,
result
);
// Lấy danh sách các lot_id thắng // Gọi API updateStatusByPrice cho mỗi item và đợi tất cả hoàn thành
const items = await this.getWonList(); const responses = await Promise.allSettled(
console.log(`📌 [${this.id}] List of won lot_ids:`, items); result.map((i) => updateStatusByPrice(i.id, i.current_price))
);
// Nếu không có item nào, thoát ra // Log kết quả của mỗi request
if (items.length === 0) { responses.forEach((response, index) => {
console.log(`⚠️ [${this.id}] No items to update.`); if (response.status === "fulfilled") {
return; console.log(`✔️ [${this.id}] Successfully updated:`, result[index]);
} else {
console.error(
`❌ [${this.id}] Update failed:`,
result[index],
response.reason
);
}
});
console.log(`🏁 [${this.id}] Finished updating the won list.`);
return responses;
}
action = async () => {
try {
const page = this.page_context;
page.on("response", async (response) => {
const request = response.request();
if (request.redirectChain().length > 0) {
if (response.url().includes(configs.WEB_CONFIGS.LANGTONS.LOGIN_URL)) {
await this.handleLogin();
}
} }
});
// Lọc danh sách `this.children` chỉ giữ lại những item có trong danh sách thắng await page.goto(this.url, { waitUntil: "networkidle2" });
const result = _.filter(this.children, (item) => _.includes(items, item.lot_id));
console.log(`✅ [${this.id}] ${result.length} items need to be updated:`, result);
// Gọi API updateStatusByPrice cho mỗi item và đợi tất cả hoàn thành await page.bringToFront();
const responses = await Promise.allSettled(result.map((i) => updateStatusByPrice(i.id, i.current_price)));
// Log kết quả của mỗi request // Set userAgent
responses.forEach((response, index) => { await page.setUserAgent(
if (response.status === 'fulfilled') { "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
console.log(`✔️ [${this.id}] Successfully updated:`, result[index]); );
} else { } catch (error) {
console.error(`❌ [${this.id}] Update failed:`, result[index], response.reason); console.log("Error [action]: ", error.message);
}
});
console.log(`🏁 [${this.id}] Finished updating the won list.`);
return responses;
} }
};
action = async () => { listen_events = async () => {
try { if (this.page_context) return;
const page = this.page_context;
page.on('response', async (response) => { await this.puppeteer_connect();
const request = response.request(); await this.action();
if (request.redirectChain().length > 0) {
if (response.url().includes(configs.WEB_CONFIGS.LANGTONS.LOGIN_URL)) {
await this.handleLogin();
}
}
});
await page.goto(this.url, { waitUntil: 'networkidle2' }); this.reloadInterval = setInterval(async () => {
try {
if (this.page_context && !this.page_context.isClosed()) {
console.log(`🔄 [${this.id}] Reloading page...`);
await this.page_context.reload({ waitUntil: "networkidle2" });
console.log(`✅ [${this.id}] Page reloaded successfully.`);
await page.bringToFront(); // this.handleUpdateWonItem();
} else {
// Set userAgent console.log(
await page.setUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'); `❌ [${this.id}] Page context is closed. Stopping reload.`
} catch (error) { );
console.log('Error [action]: ', error.message); clearInterval(this.reloadInterval);
} }
}; } catch (error) {
console.error(`🚨 [${this.id}] Error reloading page:`, error.message);
listen_events = async () => { }
if (this.page_context) return; }, 60000); // 1p reload
};
await this.puppeteer_connect();
await this.action();
this.reloadInterval = setInterval(async () => {
try {
if (this.page_context && !this.page_context.isClosed()) {
console.log(`🔄 [${this.id}] Reloading page...`);
await this.page_context.reload({ waitUntil: 'networkidle2' });
console.log(`✅ [${this.id}] Page reloaded successfully.`);
// this.handleUpdateWonItem();
} else {
console.log(`❌ [${this.id}] Page context is closed. Stopping reload.`);
clearInterval(this.reloadInterval);
}
} catch (error) {
console.error(`🚨 [${this.id}] Error reloading page:`, error.message);
}
}, 60000); // 1p reload
};
} }

View File

@ -1,409 +1,510 @@
import _ from 'lodash'; import _ from "lodash";
import { outBid, pushPrice, updateBid } from '../../system/apis/bid.js'; import { outBid, pushPrice, updateBid } from "../../system/apis/bid.js";
import { sendMessage } from '../../system/apis/notification.js'; import { sendMessage } from "../../system/apis/notification.js";
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 CONSTANTS from "../../system/constants.js";
import { convertAETtoUTC, isTimeReached, removeFalsyValues, takeSnapshot } from '../../system/utils.js'; import {
import { ProductBid } from '../product-bid.js'; convertAETtoUTC,
isTimeReached,
removeFalsyValues,
takeSnapshot,
} from "../../system/utils.js";
import { ProductBid } from "../product-bid.js";
export class LangtonsProductBid extends ProductBid { export class LangtonsProductBid extends ProductBid {
constructor({ ...prev }) { constructor({ ...prev }) {
super(prev); super(prev);
}
// Hàm lấy thời gian kết thúc từ trang web
async getCloseTime() {
try {
// Kiểm tra xem có context của trang web không, nếu không thì trả về null
if (!this.page_context) return null;
await this.page_context.waitForSelector(".site-timezone", {
timeout: 2000,
});
const time = await this.page_context.evaluate(() => {
const el = document.querySelector(".site-timezone");
return el ? el.innerText : null;
});
return time ? convertAETtoUTC(time) : null;
// return new Date(Date.now() + 6 * 60 * 1000).toUTCString();
} catch (error) {
// Nếu có lỗi xảy ra trong quá trình lấy thời gian, trả về null
return null;
}
}
async waitForApiResponse(timeout = 15000) {
if (!this.page_context) {
console.error(`❌ [${this.id}] Error: page_context is undefined.`);
return null;
} }
// Hàm lấy thời gian kết thúc từ trang web return new Promise((resolve) => {
async getCloseTime() { const onResponse = async (response) => {
try { try {
// Kiểm tra xem có context của trang web không, nếu không thì trả về null if (
if (!this.page_context) return null; !response ||
!response
await this.page_context.waitForSelector('.site-timezone', { timeout: 2000 }); .request()
const time = await this.page_context.evaluate(() => { .url()
const el = document.querySelector('.site-timezone'); .includes(configs.WEB_CONFIGS.LANGTONS.API_CALL_TO_TRACKING)
return el ? el.innerText : null; ) {
});
return time ? convertAETtoUTC(time) : null;
// return new Date(Date.now() + 6 * 60 * 1000).toUTCString();
} catch (error) {
// Nếu có lỗi xảy ra trong quá trình lấy thời gian, trả về null
return null;
}
}
async waitForApiResponse(timeout = 15000) {
if (!this.page_context) {
console.error(`❌ [${this.id}] Error: page_context is undefined.`);
return null;
}
return new Promise((resolve) => {
const onResponse = async (response) => {
try {
if (!response || !response.request().url().includes(configs.WEB_CONFIGS.LANGTONS.API_CALL_TO_TRACKING)) {
return;
}
clearTimeout(timer); // Hủy timeout nếu có phản hồi
this.page_context.off('response', onResponse); // Gỡ bỏ listener
const data = await response.json();
resolve(data);
} catch (error) {
console.error(`❌ [${this.id}] Error while parsing response:`, error?.message);
resolve(null);
}
};
const timer = setTimeout(async () => {
console.log(`⏳ [${this.id}] Timeout: No response received within ${timeout / 1000}s`);
this.page_context.off('response', onResponse); // Gỡ bỏ listener khi timeout
await this.page_context.reload({ waitUntil: 'networkidle0' }); // reload page
console.log(`🔁 [${this.id}] Reload page in waitForApiResponse`);
resolve(null);
}, timeout);
this.page_context.on('response', onResponse);
});
}
async getName() {
try {
if (!this.page_context) return null;
await this.page_context.waitForSelector('.product-name', { timeout: 3000 });
return await this.page_context.evaluate(() => {
const el = document.querySelector('.product-name');
return el ? el.innerText : null;
});
} catch (error) {
return null;
}
}
async handleUpdateBid({ lot_id, close_time, name, current_price, reserve_price, model }) {
const response = await updateBid(this.id, { lot_id, close_time, name, current_price, reserve_price: Number(reserve_price) || 0, model });
if (response) {
this.lot_id = response.lot_id;
this.close_time = response.close_time;
this.start_bid_time = response.start_bid_time;
}
}
update = async () => {
if (!this.page_context) return;
console.log(`🔄 [${this.id}] Call update for ID: ${this.id}`);
// 📌 Lấy thời gian kết thúc đấu giá từ giao diện
const close_time = await this.getCloseTime();
console.log(`⏳ [${this.id}] Retrieved close time: ${close_time}`);
// 📌 Lấy tên sản phẩm hoặc thông tin liên quan
const name = await this.getName();
console.log(`📌 [${this.id}] Retrieved name: ${name}`);
// 📌 Chờ phản hồi API từ trang, tối đa 10 giây
const result = await this.waitForApiResponse();
// 📌 Nếu không có dữ liệu trả về thì dừng
if (!result) {
console.log(`⚠️ [${this.id}] No valid data received, skipping update.`);
return; return;
} }
// 📌 Loại bỏ các giá trị không hợp lệ và bổ sung thông tin cần thiết clearTimeout(timer); // Hủy timeout nếu có phản hồi
const data = removeFalsyValues( this.page_context.off("response", onResponse); // Gỡ bỏ listener
{
model: result?.pid || null, const data = await response.json();
lot_id: result?.lotId || null, resolve(data);
reserve_price: 21, //test } catch (error) {
// reserve_price: result.lotData?.minimumBid || null, console.error(
// current_price: result.lotData?.currentMaxBid || null, `❌ [${this.id}] Error while parsing response:`,
current_price: 20, // test error?.message
// close_time: close_time && !this.close_time ? String(close_time) : null, );
close_time: close_time ? String(close_time) : null, resolve(null);
name, }
}, };
// [],
['close_time'], const timer = setTimeout(async () => {
console.log(
`⏳ [${this.id}] Timeout: No response received within ${
timeout / 1000
}s`
);
this.page_context.off("response", onResponse); // Gỡ bỏ listener khi timeout
await this.page_context.reload({ waitUntil: "networkidle0" }); // reload page
console.log(`🔁 [${this.id}] Reload page in waitForApiResponse`);
resolve(null);
}, timeout);
this.page_context.on("response", onResponse);
});
}
async getName() {
try {
if (!this.page_context) return null;
await this.page_context.waitForSelector(".product-name", {
timeout: 3000,
});
return await this.page_context.evaluate(() => {
const el = document.querySelector(".product-name");
return el ? el.innerText : null;
});
} catch (error) {
return null;
}
}
async handleUpdateBid({
lot_id,
close_time,
name,
current_price,
reserve_price,
model,
}) {
const response = await updateBid(this.id, {
lot_id,
close_time,
name,
current_price,
reserve_price: Number(reserve_price) || 0,
model,
});
if (response) {
this.lot_id = response.lot_id;
this.close_time = response.close_time;
this.start_bid_time = response.start_bid_time;
}
}
update = async () => {
if (!this.page_context) return;
console.log(`🔄 [${this.id}] Call update for ID: ${this.id}`);
// 📌 Lấy thời gian kết thúc đấu giá từ giao diện
const close_time = await this.getCloseTime();
console.log(`⏳ [${this.id}] Retrieved close time: ${close_time}`);
// 📌 Lấy tên sản phẩm hoặc thông tin liên quan
const name = await this.getName();
console.log(`📌 [${this.id}] Retrieved name: ${name}`);
// 📌 Chờ phản hồi API từ trang, tối đa 10 giây
const result = await this.waitForApiResponse();
// 📌 Nếu không có dữ liệu trả về thì dừng
if (!result) {
console.log(`⚠️ [${this.id}] No valid data received, skipping update.`);
return;
}
// 📌 Loại bỏ các giá trị không hợp lệ và bổ sung thông tin cần thiết
const data = removeFalsyValues(
{
model: result?.pid || null,
lot_id: result?.lotId || null,
reserve_price: result.lotData?.minimumBid || null,
current_price: result.lotData?.currentMaxBid || null,
// close_time: close_time && !this.close_time ? String(close_time) : null,
close_time: close_time ? String(close_time) : null,
name,
},
// [],
["close_time"]
);
console.log(`🚀 [${this.id}] Processed data ready for update`);
// 📌 Gửi dữ liệu cập nhật lên hệ thống
await this.handleUpdateBid(data);
console.log("✅ Update successful!");
return { ...response, name, close_time };
};
async getContinueShopButton() {
try {
if (!this.page_context) return null;
await this.page_context.waitForSelector(
".btn.btn-block.btn-primary.error.continue-shopping",
{ timeout: 3000 }
);
return await this.page_context.evaluate(() => {
const el = document.querySelector(
".btn.btn-block.btn-primary.error.continue-shopping"
); );
console.log(`🚀 [${this.id}] Processed data ready for update`); return el;
});
} catch (error) {
return null;
}
}
// 📌 Gửi dữ liệu cập nhật lên hệ thống async handlePlaceBid() {
await this.handleUpdateBid(data); if (!this.page_context) {
console.log(
`⚠️ [${this.id}] No page context found, aborting bid process.`
);
return;
}
const page = this.page_context;
console.log('✅ Update successful!'); if (global[`IS_PLACE_BID-${this.id}`]) {
console.log(`⚠️ [${this.id}] Bid is already in progress, skipping.`);
return { ...response, name, close_time }; return;
};
async getContinueShopButton() {
try {
if (!this.page_context) return null;
await this.page_context.waitForSelector('.btn.btn-block.btn-primary.error.continue-shopping', { timeout: 3000 });
return await this.page_context.evaluate(() => {
const el = document.querySelector('.btn.btn-block.btn-primary.error.continue-shopping');
return el;
});
} catch (error) {
return null;
}
} }
async handlePlaceBid() { try {
if (!this.page_context) { console.log(`🔄 [${this.id}] Starting bid process...`);
console.log(`⚠️ [${this.id}] No page context found, aborting bid process.`); global[`IS_PLACE_BID-${this.id}`] = true;
const continueShopBtn = await this.getContinueShopButton();
if (continueShopBtn) {
console.log(
`⚠️ [${this.id}] Outbid detected, calling outBid function.`
);
await outBid(this.id);
return;
}
// Kiểm tra nếu giá hiện tại lớn hơn giá tối đa cộng thêm giá cộng thêm
if (this.current_price > this.max_price + this.plus_price) {
console.log(`⚠️ [${this.id}] Outbid bid`); // Ghi log cảnh báo nếu giá hiện tại vượt quá mức tối đa cho phép
return; // Dừng hàm nếu giá đã vượt qua giới hạn
}
// Kiểm tra thời gian bid
if (this.start_bid_time && !isTimeReached(this.start_bid_time)) {
console.log(
`⏳ [${this.id}] Not yet time to bid. Skipping Product: ${
this.name || "None"
}`
);
return;
}
// Đợi phản hồi từ API
const response = await this.waitForApiResponse();
// Kiểm tra nếu phản hồi không tồn tại hoặc nếu giá đấu của người dùng bằng với giá tối đa hiện tại
if (
!response ||
(response?.lotData?.myBid &&
response.lotData.myBid == this.max_price) ||
response?.lotData?.minimumBid > this.max_price
) {
console.log(
`⚠️ [${this.id}] No response or myBid equals max_price:`,
response
); // Ghi log nếu không có phản hồi hoặc giá đấu của người dùng bằng giá tối đa
return; // Nếu không có phản hồi hoặc giá đấu bằng giá tối đa thì dừng hàm
}
// Kiểm tra nếu dữ liệu trong response có tồn tại và trạng thái đấu giá (bidStatus) không phải là 'None'
if (
response.lotData &&
response.lotData?.bidStatus !== "None" &&
this.max_price == response?.lotData.myBid
) {
console.log(
`✔️ [${this.id}] Bid status is not 'None'. Current bid status:`,
response.lotData?.bidStatus
); // Ghi log nếu trạng thái đấu giá không phải 'None'
return; // Nếu trạng thái đấu giá không phải là 'None', dừng hàm
}
const bidHistoriesItem = _.maxBy(this.histories, "price");
console.log(`📜 [${this.id}] Current bid history:`, this.histories);
if (
bidHistoriesItem &&
bidHistoriesItem?.price === this.current_price &&
this.max_price == response?.lotData.myBid
) {
console.log(
`🔄 [${this.id}] You have already bid on this item! (Bid Price: ${bidHistoriesItem.price})`
);
return;
}
console.log(
`💰 [${this.id}] Placing a bid with amount: ${this.reserve_price}`
);
// 📌 Làm rỗng ô input trước khi nhập giá đấu
await page.evaluate(() => {
document.querySelector("#place-bid").value = "";
});
console.log(`📝 [${this.id}] Cleared bid input field.`);
// 📌 Nhập giá đấu vào ô input
await page.type("#place-bid", String(this.max_price), { delay: 800 });
console.log(`✅ [${this.id}] Entered bid amount: ${this.max_price}`);
// 📌 Lấy giá trị thực tế từ ô input sau khi nhập
const bidValue = await page.evaluate(
() => document.querySelector("#place-bid").value
);
console.log(`🔍 Entered bid value: ${bidValue}`);
// 📌 Kiểm tra nếu giá trị nhập vào không khớp với giá trị mong muốn
if (!bidValue || bidValue !== String(this.max_price)) {
console.log(`❌ Incorrect bid amount! Received: ${bidValue}`);
return; // Dừng thực hiện nếu giá trị nhập sai
}
// 📌 Nhấn nút "Place Bid"
await page.click(
".place-bid-submit .btn.btn-primary.btn-block.place-bid-btn",
{ delay: 5000 }
);
console.log(`🖱️ [${this.id}] Clicked "Place Bid" button.`);
console.log(`📩 [${this.id}] Bid submitted, waiting for navigation...`);
// 📌 Chờ trang load lại để cập nhật trạng thái đấu giá
await page.waitForNavigation({
timeout: 8000,
waitUntil: "domcontentloaded",
});
console.log(`🔄 [${this.id}] Page reloaded, checking bid status...`);
const { lotData } = await this.waitForApiResponse();
console.log(`📡 [${this.id}] API Response received:`, lotData);
// 📌 Kiểm tra trạng thái đấu giá từ API
if (lotData?.myBid == this.max_price) {
console.log(`📸 [${this.id}] Taking bid success snapshot...`);
await takeSnapshot(
page,
this,
"bid-success",
CONSTANTS.TYPE_IMAGE.SUCCESS
);
sendMessage(this);
console.log(`✅ [${this.id}] Bid placed successfully!`);
return;
}
console.log(
`⚠️ [${this.id}] Bid action completed, but status is still "None".`
);
} catch (error) {
console.log(`🚨 [${this.id}] Error placing bid: ${error.message}`);
} finally {
console.log(`🔚 [${this.id}] Resetting bid flag.`);
global[`IS_PLACE_BID-${this.id}`] = false;
}
}
async handleCreateLogsOnServer(data) {
const values = data.map((item) => {
return {
model: item.pid,
lot_id: item.lotId,
out_price: item.lotData.minimumBid || 0,
raw_data: JSON.stringify(item),
};
});
await createOutBidLog(values);
}
async gotoLink() {
const page = this.page_context;
if (page.isClosed()) {
console.error(`❌ [${this.id}] Page has been closed, cannot navigate.`);
return;
}
console.log(`🔄 [${this.id}] Starting the bidding process...`);
try {
console.log(`🌐 [${this.id}] Navigating to: ${this.url} ...`);
await page.goto(this.url, { waitUntil: "networkidle2" });
console.log(`✅ [${this.id}] Successfully navigated to: ${this.url}`);
console.log(`🖥️ [${this.id}] Bringing tab to the foreground...`);
await page.bringToFront();
console.log(`🛠️ [${this.id}] Setting custom user agent...`);
await page.setUserAgent(
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
);
console.log(`🎯 [${this.id}] Listening for API responses...`);
// // 🔥 Xóa tất cả event chặn request trước khi thêm mới
// page.removeAllListeners('request');
// await page.setRequestInterception(true);
// page.on('request', (request) => {
// if (request.url().includes(configs.WEB_CONFIGS.LANGTONS.API_CALL_TO_TRACKING)) {
// console.log('🚀 Fake response cho request:', request.url());
// const fakeData = fs.readFileSync('./data/fake-out-lot-langtons.json', 'utf8');
// request.respond({
// status: 200,
// contentType: 'application/json',
// body: fakeData,
// });
// } else {
// try {
// request.continue(); // ⚠️ Chỉ tiếp tục nếu request chưa bị chặn
// } catch (error) {
// console.error('⚠️ Lỗi khi tiếp tục request:', error.message);
// }
// }
// });
const onResponse = async (response) => {
const url = response?.request()?.url();
if (
!url ||
!url.includes(configs.WEB_CONFIGS.LANGTONS.API_CALL_TO_TRACKING)
) {
return;
}
try {
const { lotData, ...prev } = await response.json();
console.log(`📜 [${this.id}] Received lotData:`, lotData);
if (!lotData || lotData.lotId !== this.lot_id) {
console.log(
`⚠️ [${this.id}] Ignored response for lotId: ${lotData?.lotId}`
);
await this.page_context.reload({ waitUntil: "networkidle0" });
console.log(`🔁 [${this.id}] Reload page in gotoLink`);
return; return;
} }
const page = this.page_context;
if (global[`IS_PLACE_BID-${this.id}`]) { console.log(`🔍 [${this.id}] Checking bid status...`);
console.log(`⚠️ [${this.id}] Bid is already in progress, skipping.`);
return;
}
try { if (["Outbid"].includes(lotData?.bidStatus)) {
console.log(`🔄 [${this.id}] Starting bid process...`); console.log(
global[`IS_PLACE_BID-${this.id}`] = true; `⚠️ [${this.id}] Outbid detected, attempting to place a new bid...`
);
const continueShopBtn = await this.getContinueShopButton(); this.handleCreateLogsOnServer([{ lotData, ...prev }]);
if (continueShopBtn) { } else if (["Winning"].includes(lotData?.bidStatus)) {
console.log(`⚠️ [${this.id}] Outbid detected, calling outBid function.`); const bidHistoriesItem = _.maxBy(this.histories, "price");
await outBid(this.id);
return; if (
!bidHistoriesItem ||
bidHistoriesItem?.price != lotData?.currentMaxBid
) {
pushPrice({
bid_id: this.id,
price: lotData?.currentMaxBid,
});
} }
}
// Kiểm tra nếu giá hiện tại lớn hơn giá tối đa cộng thêm giá cộng thêm if (
if (this.current_price > this.max_price + this.plus_price) { lotData.myBid &&
console.log(`⚠️ [${this.id}] Outbid bid`); // Ghi log cảnh báo nếu giá hiện tại vượt quá mức tối đa cho phép this.max_price &&
return; // Dừng hàm nếu giá đã vượt qua giới hạn this.max_price != lotData.myBid
} ) {
this.handlePlaceBid();
// Kiểm tra thời gian bid }
if (this.start_bid_time && !isTimeReached(this.start_bid_time)) {
console.log(`⏳ [${this.id}] Not yet time to bid. Skipping Product: ${this.name || 'None'}`);
return;
}
// Đợi phản hồi từ API
const response = await this.waitForApiResponse();
// Kiểm tra nếu phản hồi không tồn tại hoặc nếu giá đấu của người dùng bằng với giá tối đa hiện tại
if (!response || (response?.lotData?.myBid && response.lotData.myBid == this.max_price) || response?.lotData?.minimumBid > this.max_price) {
console.log(`⚠️ [${this.id}] No response or myBid equals max_price:`, response); // Ghi log nếu không có phản hồi hoặc giá đấu của người dùng bằng giá tối đa
return; // Nếu không có phản hồi hoặc giá đấu bằng giá tối đa thì dừng hàm
}
// Kiểm tra nếu dữ liệu trong response có tồn tại và trạng thái đấu giá (bidStatus) không phải là 'None'
if (response.lotData && response.lotData?.bidStatus !== 'None' && this.max_price == response?.lotData.myBid) {
console.log(`✔️ [${this.id}] Bid status is not 'None'. Current bid status:`, response.lotData?.bidStatus); // Ghi log nếu trạng thái đấu giá không phải 'None'
return; // Nếu trạng thái đấu giá không phải là 'None', dừng hàm
}
const bidHistoriesItem = _.maxBy(this.histories, 'price');
console.log(`📜 [${this.id}] Current bid history:`, this.histories);
if (bidHistoriesItem && bidHistoriesItem?.price === this.current_price && this.max_price == response?.lotData.myBid) {
console.log(`🔄 [${this.id}] You have already bid on this item! (Bid Price: ${bidHistoriesItem.price})`);
return;
}
console.log(`💰 [${this.id}] Placing a bid with amount: ${this.reserve_price}`);
// 📌 Làm rỗng ô input trước khi nhập giá đấu
await page.evaluate(() => {
document.querySelector('#place-bid').value = '';
});
console.log(`📝 [${this.id}] Cleared bid input field.`);
// 📌 Nhập giá đấu vào ô input
await page.type('#place-bid', String(this.max_price), { delay: 800 });
console.log(`✅ [${this.id}] Entered bid amount: ${this.max_price}`);
// 📌 Lấy giá trị thực tế từ ô input sau khi nhập
const bidValue = await page.evaluate(() => document.querySelector('#place-bid').value);
console.log(`🔍 Entered bid value: ${bidValue}`);
// 📌 Kiểm tra nếu giá trị nhập vào không khớp với giá trị mong muốn
if (!bidValue || bidValue !== String(this.max_price)) {
console.log(`❌ Incorrect bid amount! Received: ${bidValue}`);
return; // Dừng thực hiện nếu giá trị nhập sai
}
// 📌 Nhấn nút "Place Bid"
await page.click('.place-bid-submit .btn.btn-primary.btn-block.place-bid-btn', { delay: 5000 });
console.log(`🖱️ [${this.id}] Clicked "Place Bid" button.`);
console.log(`📩 [${this.id}] Bid submitted, waiting for navigation...`);
// 📌 Chờ trang load lại để cập nhật trạng thái đấu giá
await page.waitForNavigation({ timeout: 8000, waitUntil: 'domcontentloaded' });
console.log(`🔄 [${this.id}] Page reloaded, checking bid status...`);
const { lotData } = await this.waitForApiResponse();
console.log(`📡 [${this.id}] API Response received:`, lotData);
// 📌 Kiểm tra trạng thái đấu giá từ API
if (lotData?.myBid == this.max_price) {
console.log(`📸 [${this.id}] Taking bid success snapshot...`);
await takeSnapshot(page, this, 'bid-success', CONSTANTS.TYPE_IMAGE.SUCCESS);
sendMessage(this);
console.log(`✅ [${this.id}] Bid placed successfully!`);
return;
}
console.log(`⚠️ [${this.id}] Bid action completed, but status is still "None".`);
} catch (error) { } catch (error) {
console.log(`🚨 [${this.id}] Error placing bid: ${error.message}`); console.error(`🚨 [${this.id}] Error parsing API response:`, error);
} finally {
console.log(`🔚 [${this.id}] Resetting bid flag.`);
global[`IS_PLACE_BID-${this.id}`] = false;
} }
};
console.log(`🔄 [${this.id}] Removing previous response listeners...`);
this.page_context.off("response", onResponse);
console.log(`📡 [${this.id}] Attaching new response listener...`);
this.page_context.on("response", onResponse);
console.log(`✅ [${this.id}] Navigation setup complete.`);
} catch (error) {
console.error(`❌ [${this.id}] Error during navigation:`, error);
} }
}
async handleCreateLogsOnServer(data) { action = async () => {
const values = data.map((item) => { try {
return { const page = this.page_context;
model: item.pid,
lot_id: item.lotId,
out_price: item.lotData.minimumBid || 0,
raw_data: JSON.stringify(item),
};
});
await createOutBidLog(values); // 📌 Kiểm tra nếu trang chưa tải đúng URL thì điều hướng đến URL mục tiêu
if (!page.url() || !page.url().includes(this.url)) {
console.log(`🔄 [${this.id}] Navigating to target URL: ${this.url}`);
await this.gotoLink();
}
await this.handlePlaceBid();
} catch (error) {
console.error(`🚨 [${this.id}] Error navigating the page: ${error}`);
} }
};
async gotoLink() {
const page = this.page_context;
if (page.isClosed()) {
console.error(`❌ [${this.id}] Page has been closed, cannot navigate.`);
return;
}
console.log(`🔄 [${this.id}] Starting the bidding process...`);
try {
console.log(`🌐 [${this.id}] Navigating to: ${this.url} ...`);
await page.goto(this.url, { waitUntil: 'networkidle2' });
console.log(`✅ [${this.id}] Successfully navigated to: ${this.url}`);
console.log(`🖥️ [${this.id}] Bringing tab to the foreground...`);
await page.bringToFront();
console.log(`🛠️ [${this.id}] Setting custom user agent...`);
await page.setUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36');
console.log(`🎯 [${this.id}] Listening for API responses...`);
// // 🔥 Xóa tất cả event chặn request trước khi thêm mới
// page.removeAllListeners('request');
// await page.setRequestInterception(true);
// page.on('request', (request) => {
// if (request.url().includes(configs.WEB_CONFIGS.LANGTONS.API_CALL_TO_TRACKING)) {
// console.log('🚀 Fake response cho request:', request.url());
// const fakeData = fs.readFileSync('./data/fake-out-lot-langtons.json', 'utf8');
// request.respond({
// status: 200,
// contentType: 'application/json',
// body: fakeData,
// });
// } else {
// try {
// request.continue(); // ⚠️ Chỉ tiếp tục nếu request chưa bị chặn
// } catch (error) {
// console.error('⚠️ Lỗi khi tiếp tục request:', error.message);
// }
// }
// });
const onResponse = async (response) => {
const url = response?.request()?.url();
if (!url || !url.includes(configs.WEB_CONFIGS.LANGTONS.API_CALL_TO_TRACKING)) {
return;
}
try {
const { lotData, ...prev } = await response.json();
console.log(`📜 [${this.id}] Received lotData:`, lotData);
if (!lotData || lotData.lotId !== this.lot_id) {
console.log(`⚠️ [${this.id}] Ignored response for lotId: ${lotData?.lotId}`);
await this.page_context.reload({ waitUntil: 'networkidle0' });
console.log(`🔁 [${this.id}] Reload page in gotoLink`);
return;
}
console.log(`🔍 [${this.id}] Checking bid status...`);
if (['Outbid'].includes(lotData?.bidStatus)) {
console.log(`⚠️ [${this.id}] Outbid detected, attempting to place a new bid...`);
this.handleCreateLogsOnServer([{ lotData, ...prev }]);
} else if (['Winning'].includes(lotData?.bidStatus)) {
const bidHistoriesItem = _.maxBy(this.histories, 'price');
if (!bidHistoriesItem || bidHistoriesItem?.price != lotData?.currentMaxBid) {
pushPrice({
bid_id: this.id,
price: lotData?.currentMaxBid,
});
}
}
if (lotData.myBid && this.max_price && this.max_price != lotData.myBid) {
this.handlePlaceBid();
}
} catch (error) {
console.error(`🚨 [${this.id}] Error parsing API response:`, error);
}
};
console.log(`🔄 [${this.id}] Removing previous response listeners...`);
this.page_context.off('response', onResponse);
console.log(`📡 [${this.id}] Attaching new response listener...`);
this.page_context.on('response', onResponse);
console.log(`✅ [${this.id}] Navigation setup complete.`);
} catch (error) {
console.error(`❌ [${this.id}] Error during navigation:`, error);
}
}
action = async () => {
try {
const page = this.page_context;
// 📌 Kiểm tra nếu trang chưa tải đúng URL thì điều hướng đến URL mục tiêu
if (!page.url() || !page.url().includes(this.url)) {
console.log(`🔄 [${this.id}] Navigating to target URL: ${this.url}`);
await this.gotoLink();
}
await this.handlePlaceBid();
} catch (error) {
console.error(`🚨 [${this.id}] Error navigating the page: ${error}`);
}
};
} }

View File

@ -1,367 +1,438 @@
import _ from 'lodash'; import _ from "lodash";
import { pushPrice, updateBid } from '../../system/apis/bid.js'; import { pushPrice, updateBid } from "../../system/apis/bid.js";
import { sendMessage } from '../../system/apis/notification.js'; import { sendMessage } from "../../system/apis/notification.js";
import configs from '../../system/config.js'; import configs from "../../system/config.js";
import { delay, extractPriceNumber, isTimeReached, removeFalsyValues } from '../../system/utils.js'; import {
import { ProductBid } from '../product-bid.js'; delay,
extractPriceNumber,
isTimeReached,
removeFalsyValues,
} from "../../system/utils.js";
import { ProductBid } from "../product-bid.js";
export class LawsonsProductBid extends ProductBid { export class LawsonsProductBid extends ProductBid {
constructor({ ...prev }) { constructor({ ...prev }) {
super(prev); super(prev);
}
async handleUpdateBid({
lot_id,
close_time,
name,
current_price,
reserve_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;
this.close_time = response.close_time;
this.start_bid_time = response.start_bid_time;
} }
}
async handleUpdateBid({ lot_id, close_time, name, current_price, reserve_price }) { async getReversePrice() {
const response = await updateBid(this.id, { lot_id, close_time, name, current_price, reserve_price: Number(reserve_price) || 0 }); try {
if (!this.page_context) return null;
if (response) { await this.page_context.waitForSelector(
this.lot_id = response.lot_id; ".select-dropdown-value.text-truncate",
this.close_time = response.close_time; { timeout: 4000 }
this.start_bid_time = response.start_bid_time; );
} const price = await this.page_context.evaluate(() => {
const el = document.querySelector(
".select-dropdown-value.text-truncate"
);
return el ? el.innerText : null;
});
return price ? extractPriceNumber(price) : null;
} catch (error) {
console.log(error.message);
return null;
} }
}
async getReversePrice() { update = async () => {
try { try {
if (!this.page_context) return null; if (!this.page_context) return;
await this.page_context.waitForSelector('.select-dropdown-value.text-truncate', { timeout: 4000 }); // if (this.updated_at) {
const price = await this.page_context.evaluate(() => { // await this.page_context.reload({ waitUntil: 'networkidle0' });
const el = document.querySelector('.select-dropdown-value.text-truncate'); // }
return el ? el.innerText : null;
const result = await this.waitApiInfo();
const reservePrice = await this.getReversePrice();
console.log({ reservePrice });
if (!result) return;
// 📌 Loại bỏ các giá trị không hợp lệ và bổ sung thông tin cần thiết
const data = removeFalsyValues(
{
lot_id: String(result?.itemView.lotId) || null,
reserve_price: reservePrice,
current_price: result?.currentBidAmount || null,
close_time: new Date(result.endTime).toUTCString() || null,
// close_time: this.close_time ? null : new Date(Date.now() + 5 * 60 * 1000).toUTCString(), //test
name: result?.itemView?.title || null,
},
// [],
["close_time"]
);
console.log(`🚀 [${this.id}] Processed data ready for update`);
// 📌 Gửi dữ liệu cập nhật lên hệ thống
await this.handleUpdateBid(data);
} catch (error) {
console.log("Error Update", error.message);
}
};
// Hàm con để fetch trong context trình duyệt
fetchFromPage = async (url) => {
return await this.page_context.evaluate(async (url) => {
try {
const res = await fetch(url, {
method: "GET",
headers: { "Content-Type": "application/json" },
});
return await res.json();
} catch (err) {
return { error: err.message };
}
}, url);
};
submitBid() {
return new Promise(async (resolve, reject) => {
if (!this.page_context || !this.model) {
console.log(`[${this.id}] Page context or model is missing.`);
reject(null);
return;
}
try {
console.log(`💰 [${this.id}] Prepared Bid Amount: ${this.max_price}`);
const result = await this.page_context.evaluate(
async (bidAmount, lotRef, url) => {
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
bidAmount,
lotRef,
v2: true,
}),
}); });
return price ? extractPriceNumber(price) : null; if (!response.ok) {
} catch (error) { throw new Error(`HTTP ${response.status}`);
console.log(error.message);
return null;
}
}
update = async () => {
try {
if (!this.page_context) return;
// if (this.updated_at) {
// await this.page_context.reload({ waitUntil: 'networkidle0' });
// }
const result = await this.waitApiInfo();
const reservePrice = await this.getReversePrice();
console.log({ reservePrice });
if (!result) return;
// 📌 Loại bỏ các giá trị không hợp lệ và bổ sung thông tin cần thiết
const data = removeFalsyValues(
{
lot_id: String(result?.itemView.lotId) || null,
reserve_price: reservePrice,
current_price: result?.currentBidAmount || null,
close_time: new Date(result.endTime).toUTCString() || null,
// close_time: this.close_time ? null : new Date(Date.now() + 5 * 60 * 1000).toUTCString(), //test
name: result?.itemView?.title || null,
},
// [],
['close_time'],
);
console.log(`🚀 [${this.id}] Processed data ready for update`);
// 📌 Gửi dữ liệu cập nhật lên hệ thống
await this.handleUpdateBid(data);
} catch (error) {
console.log('Error Update', error.message);
}
};
// Hàm con để fetch trong context trình duyệt
fetchFromPage = async (url) => {
return await this.page_context.evaluate(async (url) => {
try {
const res = await fetch(url, {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
});
return await res.json();
} catch (err) {
return { error: err.message };
}
}, url);
};
submitBid() {
return new Promise(async (resolve, reject) => {
if (!this.page_context || !this.model) {
console.log(`[${this.id}] Page context or model is missing.`);
reject(null);
return;
} }
try { return await response.json();
console.log(`💰 [${this.id}] Prepared Bid Amount: ${this.max_price}`); },
this.max_price,
this.model,
configs.WEB_CONFIGS.LAWSONS.API_CHECKOUT
);
const result = await this.page_context.evaluate( console.log("🧾 API Bid Result:", {
async (bidAmount, lotRef, url) => { bid_amount: this.max_price,
const response = await fetch(url, { result,
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
bidAmount,
lotRef,
v2: true,
}),
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return await response.json();
},
this.max_price,
this.model,
configs.WEB_CONFIGS.LAWSONS.API_CHECKOUT,
);
console.log('🧾 API Bid Result:', {
bid_amount: this.max_price,
result,
});
if (!result?.data?.orderBidResponse?.success) reject(null);
resolve(result);
} catch (err) {
console.log(`[${this.id}] Failed to submit bid: ${err.message}`);
reject(null);
}
}); });
if (!result?.data?.orderBidResponse?.success) reject(null);
resolve(result);
} catch (err) {
console.log(`[${this.id}] Failed to submit bid: ${err.message}`);
reject(null);
}
});
}
async handlePlaceBid() {
// Kiểm tra xem có page context không, nếu không có thì kết thúc quá trình đấu giá
if (!this.page_context) {
console.log(
`⚠️ [${this.id}] No page context found, aborting bid process.`
);
return;
}
const page = this.page_context;
// Kiểm tra xem đấu giá đã đang diễn ra chưa. Nếu có thì không thực hiện nữa
if (global[`IS_PLACE_BID-${this.id}`]) {
console.log(`⚠️ [${this.id}] Bid is already in progress, skipping.`);
return;
} }
async handlePlaceBid() { try {
// Kiểm tra xem có page context không, nếu không có thì kết thúc quá trình đấu giá console.log(`🔄 [${this.id}] Starting bid process...`);
if (!this.page_context) { // Đánh dấu rằng đang thực hiện quá trình đấu giá để tránh đấu lại
console.log(`⚠️ [${this.id}] No page context found, aborting bid process.`); global[`IS_PLACE_BID-${this.id}`] = true;
return;
}
const page = this.page_context;
// Kiểm tra xem đấu giá đã đang diễn ra chưa. Nếu có thì không thực hiện nữa // Kiểm tra xem giá hiện tại có vượt qua mức giá tối đa chưa
if (global[`IS_PLACE_BID-${this.id}`]) { if (this.current_price > this.max_price + this.plus_price) {
console.log(`⚠️ [${this.id}] Bid is already in progress, skipping.`); console.log(`⚠️ [${this.id}] Outbid bid`);
return; return; // Nếu giá hiện tại vượt quá mức giá tối đa thì dừng lại
}
// Kiểm tra thời gian đấu giá
if (this.start_bid_time && !isTimeReached(this.start_bid_time)) {
console.log(
`⏳ [${this.id}] Not yet time to bid. Skipping Product: ${
this.name || "None"
}`
);
return; // Nếu chưa đến giờ đấu giá thì bỏ qua
}
// Đợi lấy thông tin API để kiểm tra tình trạng đấu giá hiện tại
const response = await this.waitApiInfo();
// Lấy giá reserve price để kiểm tra
const reservePrice = await this.getReversePrice();
// Kiểm tra nếu có lý do nào khiến không thể tiếp tục đấu giá
const shouldStop =
!response ||
response?.currentBidAmount > this.max_price + this.plus_price ||
response.isOutBid != true ||
!reservePrice ||
reservePrice > this.max_price + this.plus_price;
if (shouldStop) {
console.log(`⚠️ [${this.id}] Stop bidding:`, {
reservePrice,
currentBidAmount: response?.currentBidAmount,
maxBidAmount: response?.maxBidAmount,
});
return; // Nếu gặp điều kiện dừng thì không thực hiện đấu giá
}
// Tìm bid history lớn nhất từ các lịch sử đấu giá của item
const bidHistoriesItem = _.maxBy(this.histories, "price");
console.log(`📜 [${this.id}] Current bid history:`, this.histories);
// Kiểm tra xem đã bid rồi chưa. Nếu đã bid rồi thì bỏ qua
if (
bidHistoriesItem &&
bidHistoriesItem?.price == this.current_price &&
this.max_price + this.plus_price == response?.maxBidAmount
) {
console.log(
`🔄 [${this.id}] You have already bid on this item! (Bid Price: ${bidHistoriesItem?.price})`
);
return;
}
if (this.reserve_price <= 0) {
console.log(`[${this.reserve_price}]`);
return;
}
console.log(
`===============Start call to submit [${this.id}] ================`
);
await delay(2000);
// Nếu chưa bid, thực hiện đặt giá
console.log(
`💰 [${this.id}] Placing a bid with amount: ${this.max_price}`
);
// Gửi bid qua API và nhận kết quả
const result = await this.submitBid();
// Nếu không có kết quả (lỗi khi gửi bid) thì dừng lại
if (!result) return;
console.log({ result });
// Gửi thông báo đã đấu giá thành công
sendMessage(this);
await this.page_context.reload({ waitUntil: "networkidle0" });
console.log(`✅ [${this.id}] Bid placed successfully!`);
} catch (error) {
// Nếu có lỗi xảy ra trong quá trình đấu giá, log lại lỗi
console.log(`🚨 [${this.id}] Error placing bid: ${error.message}`);
} finally {
// Đảm bảo luôn reset trạng thái đấu giá sau khi hoàn thành
console.log(`🔚 [${this.id}] Resetting bid flag.`);
global[`IS_PLACE_BID-${this.id}`] = false;
}
}
async waitApiInfo() {
if (!this.page_context) {
console.error(`❌ [${this.id}] Error: page_context is undefined.`);
return null;
}
const infoUrl = configs.WEB_CONFIGS.LAWSONS.API_DETAIL_INFO(this.model);
const detailUrl = configs.WEB_CONFIGS.LAWSONS.API_DETAIL_PRODUCT(
this.model
);
const [info, detailData] = await Promise.all([
this.fetchFromPage(infoUrl),
this.fetchFromPage(detailUrl),
]);
return { ...info, ...detailData };
}
async trackingOutbid() {
if (!this.page_context) return;
try {
const onResponse = async (response) => {
const url = response?.request()?.url();
if (
!url ||
!url.includes(configs.WEB_CONFIGS.LAWSONS.API_DETAIL_INFO(this.model))
) {
return;
} }
try { try {
console.log(`🔄 [${this.id}] Starting bid process...`); const result = await response.json();
// Đánh dấu rằng đang thực hiện quá trình đấu giá để tránh đấu lại
global[`IS_PLACE_BID-${this.id}`] = true;
// Kiểm tra xem giá hiện tại có vượt qua mức giá tối đa chưa if (!result) return;
if (this.current_price > this.max_price + this.plus_price) {
console.log(`⚠️ [${this.id}] Outbid bid`);
return; // Nếu giá hiện tại vượt quá mức giá tối đa thì dừng lại
}
// Kiểm tra thời gian đấu giá console.log(`📈 [${this.id}] Bid data: `, result);
if (this.start_bid_time && !isTimeReached(this.start_bid_time)) {
console.log(`⏳ [${this.id}] Not yet time to bid. Skipping Product: ${this.name || 'None'}`);
return; // Nếu chưa đến giờ đấu giá thì bỏ qua
}
// Đợi lấy thông tin API để kiểm tra tình trạng đấu giá hiện tại const { maxBidAmount, currentBidAmount, isOutBid } = result;
const response = await this.waitApiInfo();
// Lấy giá reserve price để kiểm tra console.log(
const reservePrice = await this.getReversePrice(); `📊 [${this.id}] API Info - maxBidAmount: ${maxBidAmount}, currentBidAmount: ${currentBidAmount}, isOutBid: ${isOutBid}`
);
// Kiểm tra nếu có lý do nào khiến không thể tiếp tục đấu giá // Lấy giá reverse (giá thấp nhất cần để thắng đấu giá)
const shouldStop = const reversePrice = await this.getReversePrice();
!response || console.log(`💰 [${this.id}] Current reverse price: ${reversePrice}`);
response?.currentBidAmount > this.max_price + this.plus_price ||
response.isOutBid != true ||
!reservePrice ||
reservePrice > this.max_price + this.plus_price;
if (shouldStop) { // Tìm ra lịch sử đấu giá có giá cao nhất trong this.histories
console.log(`⚠️ [${this.id}] Stop bidding:`, { reservePrice, currentBidAmount: response?.currentBidAmount, maxBidAmount: response?.maxBidAmount }); const bidHistoriesItem = _.maxBy(this.histories, "price");
return; // Nếu gặp điều kiện dừng thì không thực hiện đấu giá console.log(
} `📈 [${this.id}] Highest local bid: ${
bidHistoriesItem?.price ?? "N/A"
}`
);
// Tìm bid history lớn nhất từ các lịch sử đấu giá của item if (!this.close_time || !this.lot_id || !this.current_price) return;
const bidHistoriesItem = _.maxBy(this.histories, 'price');
console.log(`📜 [${this.id}] Current bid history:`, this.histories);
// Kiểm tra xem đã bid rồi chưa. Nếu đã bid rồi thì bỏ qua // Nếu chưa từng đặt giá và có giá tối đa (maxBidAmount), thì push giá đó vào histories
if (bidHistoriesItem && bidHistoriesItem?.price == this.current_price && this.max_price + this.plus_price == response?.maxBidAmount) { if (
console.log(`🔄 [${this.id}] You have already bid on this item! (Bid Price: ${bidHistoriesItem?.price})`); (!bidHistoriesItem && maxBidAmount) ||
return; (bidHistoriesItem?.price != currentBidAmount &&
} currentBidAmount == maxBidAmount)
) {
if (this.reserve_price <= 0) { console.log(
console.log(`[${this.reserve_price}]`); `🆕 [${this.id}] No previous bid found. Placing initial bid at ${maxBidAmount}.`
return; );
} pushPrice({
bid_id: this.id,
console.log(`===============Start call to submit [${this.id}] ================`); price: currentBidAmount,
});
await delay(20000); }
// Nếu chưa bid, thực hiện đặt giá
console.log(`💰 [${this.id}] Placing a bid with amount: ${this.max_price}`);
// Gửi bid qua API và nhận kết quả
const result = await this.submitBid();
// Nếu không có kết quả (lỗi khi gửi bid) thì dừng lại
if (!result) return;
console.log({ result });
// Gửi thông báo đã đấu giá thành công
sendMessage(this);
await this.page_context.reload({ waitUntil: 'networkidle0' });
console.log(`✅ [${this.id}] Bid placed successfully!`);
} catch (error) {
// Nếu có lỗi xảy ra trong quá trình đấu giá, log lại lỗi
console.log(`🚨 [${this.id}] Error placing bid: ${error.message}`);
} finally {
// Đảm bảo luôn reset trạng thái đấu giá sau khi hoàn thành
console.log(`🔚 [${this.id}] Resetting bid flag.`);
global[`IS_PLACE_BID-${this.id}`] = false;
}
}
async waitApiInfo() {
if (!this.page_context) {
console.error(`❌ [${this.id}] Error: page_context is undefined.`);
return null;
}
const infoUrl = configs.WEB_CONFIGS.LAWSONS.API_DETAIL_INFO(this.model);
const detailUrl = configs.WEB_CONFIGS.LAWSONS.API_DETAIL_PRODUCT(this.model);
const [info, detailData] = await Promise.all([this.fetchFromPage(infoUrl), this.fetchFromPage(detailUrl)]);
return { ...info, ...detailData };
}
async trackingOutbid() {
if (!this.page_context) return;
try {
const onResponse = async (response) => {
const url = response?.request()?.url();
if (!url || !url.includes(configs.WEB_CONFIGS.LAWSONS.API_DETAIL_INFO(this.model))) {
return;
}
try {
const result = await response.json();
if (!result) return;
console.log(`📈 [${this.id}] Bid data: `, result);
const { maxBidAmount, currentBidAmount, isOutBid } = result;
console.log(`📊 [${this.id}] API Info - maxBidAmount: ${maxBidAmount}, currentBidAmount: ${currentBidAmount}, isOutBid: ${isOutBid}`);
// Lấy giá reverse (giá thấp nhất cần để thắng đấu giá)
const reversePrice = await this.getReversePrice();
console.log(`💰 [${this.id}] Current reverse price: ${reversePrice}`);
// Tìm ra lịch sử đấu giá có giá cao nhất trong this.histories
const bidHistoriesItem = _.maxBy(this.histories, 'price');
console.log(`📈 [${this.id}] Highest local bid: ${bidHistoriesItem?.price ?? 'N/A'}`);
if (!this.close_time || !this.lot_id || !this.current_price) return;
// Nếu chưa từng đặt giá và có giá tối đa (maxBidAmount), thì push giá đó vào histories
if ((!bidHistoriesItem && maxBidAmount) || (bidHistoriesItem?.price != currentBidAmount && currentBidAmount == maxBidAmount)) {
console.log(`🆕 [${this.id}] No previous bid found. Placing initial bid at ${maxBidAmount}.`);
pushPrice({
bid_id: this.id,
price: currentBidAmount,
});
}
// Nếu giá hiện tại cao hơn giá mình đã đặt, và reversePrice vẫn trong giới hạn cho phép, và đang bị outbid thì sẽ đặt giá tiếp
if (reversePrice <= this.max_price + this.plus_price && isOutBid && currentBidAmount <= this.max_price + this.plus_price && this.max_price != maxBidAmount) {
console.log(`⚠️ [${this.id}] Outbid detected. Reverse price acceptable. Placing a new bid...`);
await this.handlePlaceBid();
} else {
console.log(`✅ [${this.id}] No bid needed. Conditions not met.`);
}
if (new Date(this.updated_at).getTime() > Date.now() - 120 * 1000) {
await this.page_context.reload({ waitUntil: 'networkidle0' });
}
} catch (error) {
console.error(`🚨 [${this.id}] Error parsing API response:`, error);
}
};
console.log(`🔄 [${this.id}] Removing previous response listeners...`);
this.page_context.off('response', onResponse);
console.log(`📡 [${this.id}] Attaching new response listener...`);
this.page_context.on('response', onResponse);
console.log(`✅ [${this.id}] Navigation setup complete.`);
} catch (error) {
console.error(`❌ [${this.id}] Error during navigation:`, error);
}
}
async gotoLink() {
const page = this.page_context;
if (page.isClosed()) {
console.error(`❌ [${this.id}] Page has been closed, cannot navigate.`);
return;
}
console.log(`🔄 [${this.id}] Starting the bidding process...`);
try {
console.log(`🌐 [${this.id}] Navigating to: ${this.url} ...`);
await page.goto(this.url, { waitUntil: 'networkidle2' });
console.log(`✅ [${this.id}] Successfully navigated to: ${this.url}`);
console.log(`🖥️ [${this.id}] Bringing tab to the foreground...`);
await page.bringToFront();
console.log(`🛠️ [${this.id}] Setting custom user agent...`);
await page.setUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36');
console.log(`🎯 [${this.id}] Listening for API responses...`);
// tracking out bid
this.trackingOutbid();
} catch (error) {
console.error(`❌ [${this.id}] Error during navigation:`, error);
}
}
action = async () => {
try {
const page = this.page_context;
// 📌 Kiểm tra nếu trang chưa tải đúng URL thì điều hướng đến URL mục tiêu
if (!page.url() || !page.url().includes(this.url)) {
console.log(`🔄 [${this.id}] Navigating to target URL: ${this.url}`);
await this.gotoLink();
}
// Nếu giá hiện tại cao hơn giá mình đã đặt, và reversePrice vẫn trong giới hạn cho phép, và đang bị outbid thì sẽ đặt giá tiếp
if (
reversePrice <= this.max_price + this.plus_price &&
isOutBid &&
currentBidAmount <= this.max_price + this.plus_price &&
this.max_price != maxBidAmount
) {
console.log(
`⚠️ [${this.id}] Outbid detected. Reverse price acceptable. Placing a new bid...`
);
await this.handlePlaceBid(); await this.handlePlaceBid();
} else {
console.log(`✅ [${this.id}] No bid needed. Conditions not met.`);
}
if (new Date(this.updated_at).getTime() > Date.now() - 120 * 1000) {
await this.page_context.reload({ waitUntil: "networkidle0" });
}
} catch (error) { } catch (error) {
console.error(`🚨 [${this.id}] Error navigating the page: ${error}`); console.error(`🚨 [${this.id}] Error parsing API response:`, error);
} }
}; };
console.log(`🔄 [${this.id}] Removing previous response listeners...`);
this.page_context.off("response", onResponse);
console.log(`📡 [${this.id}] Attaching new response listener...`);
this.page_context.on("response", onResponse);
console.log(`✅ [${this.id}] Navigation setup complete.`);
} catch (error) {
console.error(`❌ [${this.id}] Error during navigation:`, error);
}
}
async gotoLink() {
const page = this.page_context;
if (page.isClosed()) {
console.error(`❌ [${this.id}] Page has been closed, cannot navigate.`);
return;
}
console.log(`🔄 [${this.id}] Starting the bidding process...`);
try {
console.log(`🌐 [${this.id}] Navigating to: ${this.url} ...`);
await page.goto(this.url, { waitUntil: "networkidle2" });
console.log(`✅ [${this.id}] Successfully navigated to: ${this.url}`);
console.log(`🖥️ [${this.id}] Bringing tab to the foreground...`);
await page.bringToFront();
console.log(`🛠️ [${this.id}] Setting custom user agent...`);
await page.setUserAgent(
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
);
console.log(`🎯 [${this.id}] Listening for API responses...`);
// tracking out bid
this.trackingOutbid();
} catch (error) {
console.error(`❌ [${this.id}] Error during navigation:`, error);
}
}
action = async () => {
try {
const page = this.page_context;
// 📌 Kiểm tra nếu trang chưa tải đúng URL thì điều hướng đến URL mục tiêu
if (!page.url() || !page.url().includes(this.url)) {
console.log(`🔄 [${this.id}] Navigating to target URL: ${this.url}`);
await this.gotoLink();
}
await this.handlePlaceBid();
} catch (error) {
console.error(`🚨 [${this.id}] Error navigating the page: ${error}`);
}
};
} }

View File

@ -0,0 +1,151 @@
import fs from "fs";
import configs from "../../system/config.js";
import { delay, getPathProfile, safeClosePage } from "../../system/utils.js";
import { ApiBid } from "../api-bid.js";
export class PicklesApiBid extends ApiBid {
reloadInterval = null;
constructor({ ...prev }) {
super(prev);
}
action = async () => {
try {
const page = this.page_context;
page.on("response", async (response) => {
const request = response.request();
if (request.redirectChain().length > 0) {
if (response.url().includes(configs.WEB_CONFIGS.PICKLES.LOGIN_URL)) {
await this.handleLogin();
}
}
});
await page.goto(this.url, { waitUntil: "networkidle2" });
await page.bringToFront();
// Set userAgent
await page.setUserAgent(
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
);
} catch (error) {
console.log("Error [action]: ", error.message);
}
};
isLogin = async () => {
if (!this.page_context) return false;
const filePath = getPathProfile(this.origin_url);
return (
!(await this.page_context.$('[name="_58_login"]')) &&
fs.existsSync(filePath)
);
};
async handleLogin() {
const page = this.page_context;
global.IS_CLEANING = false;
const filePath = getPathProfile(this.origin_url);
await page.waitForNavigation({ waitUntil: "domcontentloaded" });
// 🛠 Check if already logged in (login input should not be visible or profile exists)
if (!(await page.$('[name="_58_login"]')) && fs.existsSync(filePath)) {
console.log(`✅ [${this.id}] Already logged in, skipping login process.`);
return;
}
if (fs.existsSync(filePath)) {
console.log(`🗑 [${this.id}] Deleting existing file: ${filePath}`);
fs.unlinkSync(filePath);
}
const children = this.children.filter((item) => item?.page_context);
console.log(
`🔍 [${this.id}] Found ${children.length} child pages to close.`
);
if (children.length > 0) {
console.log(`🛑 [${this.id}] Closing child pages...`);
await Promise.all(
children.map((item) => {
console.log(
`➡ [${this.id}] Closing child page with context: ${item?.page_context}`
);
return safeClosePage(item);
})
);
console.log(
`➡ [${this.id}] Closing main page context: ${this.page_context}`
);
await safeClosePage(this);
}
console.log(`🔑 [${this.id}] Starting login process...`);
try {
// ⌨ Enter email
console.log(`✍ [${this.id}] Entering email:`, this.username);
await page.type('[name="_58_login"]', this.username, { delay: 100 });
// ⌨ Enter password
console.log(`✍ [${this.id}] Entering password...`);
await page.type('[name="_58_password"]', this.password, { delay: 150 });
// 🚀 Click the login button
console.log(`🔘 [${this.id}] Clicking the "Login" button`);
await page.click("#sign-in-btn", { delay: 92 });
await page.waitForNavigation({
timeout: 8000,
waitUntil: "domcontentloaded",
});
if (this.page_context.url() == this.url) {
// 📂 Save session context to avoid re-login
await this.saveContext();
console.log(`✅ [${this.id}] Login successful!`);
} else {
console.log(`❌ [${this.id}] Login Failure!`);
}
} catch (error) {
console.error(
`❌ [${this.id}] Error during login process:`,
error.message
);
} finally {
global.IS_CLEANING = true;
}
}
listen_events = async () => {
if (this.page_context) return;
await this.puppeteer_connect();
await this.action();
this.reloadInterval = setInterval(async () => {
try {
if (this.page_context && !this.page_context.isClosed()) {
console.log(`🔄 [${this.id}] Reloading page...`);
await this.page_context.reload({ waitUntil: "networkidle2" });
console.log(`✅ [${this.id}] Page reloaded successfully.`);
} else {
console.log(
`❌ [${this.id}] Page context is closed. Stopping reload.`
);
clearInterval(this.reloadInterval);
}
} catch (error) {
console.error(`🚨 [${this.id}] Error reloading page:`, error.message);
}
}, 60000); // 1p reload
};
}

View File

@ -0,0 +1,479 @@
import _ from "lodash";
import { pushPrice, updateBid } from "../../system/apis/bid.js";
import configs from "../../system/config.js";
import { delay, isTimeReached, removeFalsyValues } from "../../system/utils.js";
import { ProductBid } from "../product-bid.js";
import { sendMessage } from "../../system/apis/notification.js";
export class PicklesProductBid extends ProductBid {
constructor({ ...prev }) {
super(prev);
}
async handleUpdateBid({
lot_id,
close_time,
name,
current_price,
reserve_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;
this.close_time = response.close_time;
this.start_bid_time = response.start_bid_time;
}
}
fetchFromPage = async (url) => {
return await this.page_context.evaluate(async (url) => {
try {
const res = await fetch(url, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
return await res.json();
} catch (err) {
return { error: err.message };
}
}, url);
};
detailData = async () => {
return await this.fetchFromPage(
configs.WEB_CONFIGS.PICKLES.API_DETAIL_PRODUCT(this.model)
);
};
getName = async () => {
if (!this.page_context) return null;
try {
return await this.page_context.$eval(
"#pd-ph-header > div:first-child > div > div:nth-child(2) > div > h1",
(el) => el.textContent
);
} catch (error) {
console.log(
"%cmodels/pickles.com.au/pickles-product-bid.js:60 error.message",
"color: #007acc;",
error.message
);
return null;
}
};
update = async () => {
try {
if (!this.page_context) return;
const result = await this.detailData();
if (!result || !result[0]) return;
const { item, bidding } = result[0];
const name = await this.getName();
console.log(
"%cmodels/pickles.com.au/pickles-product-bid.js:83 item",
"color: #007acc;",
bidding
);
// 📌 Loại bỏ các giá trị không hợp lệ và bổ sung thông tin cần thiết
const data = removeFalsyValues(
{
lot_id: String(item?.id) || null,
reserve_price: bidding?.minimumBidAmount || null,
current_price: bidding?.currentActualBid || null,
close_time: new Date(item?.itemBidEndTimestamp).toUTCString() || null,
name: name || null,
},
["close_time"]
);
console.log(`🚀 [${this.id}] Processed data ready for update`);
// 📌 Gửi dữ liệu cập nhật lên hệ thống
await this.handleUpdateBid(data);
await this.page_context.reload({ waitUntil: "networkidle2" });
await this.page_context.waitForNavigation({
timeout: 8000,
waitUntil: "domcontentloaded",
});
} catch (error) {
console.log("Error Update", error.message);
}
};
submitBid() {
return new Promise(async (resolve, reject) => {
if (!this.page_context || !this.lot_id) {
console.log(`[${this.id}] Page context or model is missing.`);
reject("Context is not define");
return;
}
try {
console.log(`💰 [${this.id}] Prepared Bid Amount: ${this.max_price}`);
const result = await this.page_context.evaluate(
async (bidAmount, lotRef, url) => {
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
itemId: lotRef,
bidValues: {
activity: "BID",
maxBid: bidAmount, // giá trị tối đa của sản phẩm
roundedMaxBid: bidAmount, // giá trị tối đa của sản phẩm
submittedBuyNowValue: null,
},
buyerFeeCalculated: false,
buyerFees: null,
dashboardRedirectUrl: null,
itemTitle: null,
productLine: null,
registrationRequired: false,
totalAmount: null,
updateDetailsRequired: null,
}),
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return await response.json();
},
this.max_price + this.plus_price,
this.lot_id,
configs.WEB_CONFIGS.PICKLES.API_CHECKOUT
);
console.log("🧾 API Bid Result:", {
bid_amount: this.max_price + this.plus_price,
result,
});
if (!result?.confirmationRequest) reject("Api call failure");
resolve(result);
} catch (err) {
console.log(`[${this.id}] Failed to submit bid: ${err.message}`);
reject(err);
}
});
}
async handlePlaceBid() {
// Kiểm tra xem có page context không, nếu không có thì kết thúc quá trình đấu giá
if (!this.page_context) {
console.log(
`⚠️ [${this.id}] No page context found, aborting bid process.`
);
return;
}
const page = this.page_context;
// Kiểm tra xem đấu giá đã đang diễn ra chưa. Nếu có thì không thực hiện nữa
if (global[`IS_PLACE_BID-${this.id}`]) {
console.log(`⚠️ [${this.id}] Bid is already in progress, skipping.`);
return;
}
try {
console.log(`🔄 [${this.id}] Starting bid process...`);
// Đánh dấu rằng đang thực hiện quá trình đấu giá để tránh đấu lại
global[`IS_PLACE_BID-${this.id}`] = true;
// Kiểm tra xem giá hiện tại có vượt qua mức giá tối đa chưa
if (this.current_price > this.max_price + this.plus_price) {
console.log(`⚠️ [${this.id}] Outbid bid`);
return; // Nếu giá hiện tại vượt quá mức giá tối đa thì dừng lại
}
// Kiểm tra thời gian đấu giá
if (this.start_bid_time && !isTimeReached(this.start_bid_time)) {
console.log(
`⏳ [${this.id}] Not yet time to bid. Skipping Product: ${
this.name || "None"
}`
);
return; // Nếu chưa đến giờ đấu giá thì bỏ qua
}
// Đợi lấy thông tin API để kiểm tra tình trạng đấu giá hiện tại
const response = await this.detailData();
if (!response) {
console.log(`[${this.id}] Can't get info data`);
return;
}
const { bidding } = response[0];
console.log(
"%cmodels/pickles.com.au/pickles-product-bid.js:157 response",
"color: #007acc;",
bidding
);
// // Kiểm tra nếu có lý do nào khiến không thể tiếp tục đấu giá
const shouldStop =
!response ||
bidding?.currentActualBid > this.max_price + this.plus_price ||
(bidding?.userItemBidStatus &&
bidding?.userItemBidStatus?.type !== "OUTBID") ||
!bidding?.minimumBidAmount ||
bidding.minimumBidAmount > this.max_price + this.plus_price;
if (shouldStop) {
console.log(`⚠️ [${this.id}] Stop bidding:`, {
reservePrice: bidding?.minimumBidAmount,
currentBidAmount: response?.currentBidAmount,
maxBidAmount: response?.maxBidAmount,
});
return; // Nếu gặp điều kiện dừng thì không thực hiện đấu giá
}
// Tìm bid history lớn nhất từ các lịch sử đấu giá của item
const bidHistoriesItem = _.maxBy(this.histories, "price");
console.log(`📜 [${this.id}] Current bid history:`, this.histories);
// Kiểm tra xem đã bid rồi chưa. Nếu đã bid rồi thì bỏ qua
if (
bidHistoriesItem &&
bidHistoriesItem?.price == this.current_price &&
this.max_price + this.plus_price == response?.maxBidAmount
) {
console.log(
`🔄 [${this.id}] You have already bid on this item! (Bid Price: ${bidHistoriesItem?.price})`
);
return;
}
if (this.reserve_price <= 0) {
console.log(`[${this.reserve_price}]`);
return;
}
console.log(
`===============Start call to submit [${this.id}] ================`
);
// waiting 2s
await delay(2000);
// Nếu chưa bid, thực hiện đặt giá
console.log(
`💰 [${this.id}] Placing a bid with amount: ${this.max_price}`
);
// Gửi bid qua API và nhận kết quả
const result = await this.submitBid();
// Nếu không có kết quả (lỗi khi gửi bid) thì dừng lại
if (!result || !result?.confirmationRequest) {
console.log(
"%cmodels/pickles.com.au/pickles-product-bid.js:289 Error when call plance bid",
"color: #007acc;",
"Error when call plance bid"
);
return;
}
console.log({ result });
// Gửi thông báo đã đấu giá thành công
// sendMessage(this);
pushPrice({
bid_id: this.id,
price: result?.yourBid || 0,
});
await this.page_context.evaluate(() => location.reload());
console.log(`✅ [${this.id}] Bid placed successfully!`);
} catch (error) {
// Nếu có lỗi xảy ra trong quá trình đấu giá, log lại lỗi
console.log(`🚨 [${this.id}] Error placing bid: ${error.message}`);
} finally {
// Đảm bảo luôn reset trạng thái đấu giá sau khi hoàn thành
console.log(`🔚 [${this.id}] Resetting bid flag.`);
global[`IS_PLACE_BID-${this.id}`] = false;
}
}
isOutBid = async () => {
try {
// Chờ tối đa 10s cho element xuất hiện
const element = await page.waitForSelector(
'[data-testid="pd-pbsb-bit-status-banner"]',
{
timeout: 10000, // 10s
visible: true, // chỉ accept khi element hiện ra
}
);
if (!element) return false; // không có thì return false
// Lấy innerHTML của element
const innerHTML = await page.evaluate((el) => el.innerHTML, element);
console.log(
"%cmodels/pickles.com.au/pickles-product-bid.js:339 {in}",
"color: #007acc;",
{ innerHTML }
);
// Kiểm tra có từ "outbid" không
return innerHTML.includes("outbid");
} catch (error) {
// Nếu lỗi (timeout hoặc gì đó) => return false
return false;
}
};
async trackingOutbid() {
if (!this.page_context) return;
if (global[`TRACKING_PROCRESS_${this.id}`]) {
console.log(`🔄 [${this.id}] Removing previous response listeners...`);
clearInterval(global[`TRACKING_PROCRESS_${this.id}`]);
}
try {
global[`TRACKING_PROCRESS_${this.id}`] = setInterval(async () => {
try {
const result = await this.detailData();
if (!result) return;
console.log(`📈 [${this.id}] Bid data: `, result);
const { item, bidding } = result[0];
console.log(
`📊 [${this.id}] API Info - minimumBidAmount: ${bidding?.minimumBidAmount}, currentActualBid: ${bidding?.currentActualBid}`
);
// Lấy giá reverse (giá thấp nhất cần để thắng đấu giá)
const reversePrice = bidding?.currentActualBid;
const currentBidAmount = bidding?.currentActualBid;
const maxBidAmount = bidding?.buyerCurrentBid?.maximumBid;
console.log(`💰 [${this.id}] Current reverse price: ${reversePrice}`);
// Tìm ra lịch sử đấu giá có giá cao nhất trong this.histories
const bidHistoriesItem = _.maxBy(this.histories, "price");
console.log(
`📈 [${this.id}] Highest local bid: ${
bidHistoriesItem?.price ?? "N/A"
}`
);
if (!this.close_time || !this.lot_id || !this.current_price) return;
// Nếu chưa từng đặt giá và có giá tối đa (maxBidAmount), thì push giá đó vào histories
if (
(!bidHistoriesItem && maxBidAmount) ||
(bidHistoriesItem?.price != currentBidAmount &&
currentBidAmount == maxBidAmount)
) {
console.log(
`🆕 [${this.id}] No previous bid found. Placing initial bid at ${maxBidAmount}.`
);
pushPrice({
bid_id: this.id,
price: Number(currentBidAmount),
});
}
// Nếu giá hiện tại cao hơn giá mình đã đặt, và reversePrice vẫn trong giới hạn cho phép, và đang bị outbid thì sẽ đặt giá tiếp
if (
reversePrice <= this.max_price + this.plus_price &&
currentBidAmount <= this.max_price + this.plus_price &&
this.max_price != maxBidAmount &&
this.histories.length > 0 &&
bidding?.currentMaximumBid !== this.max_price + this.plus_price
) {
console.log(
`⚠️ [${this.id}] Outbid detected. Reverse price acceptable. Placing a new bid...`
);
await this.handlePlaceBid();
} else {
console.log(`✅ [${this.id}] No bid needed. Conditions not met.`);
}
} catch (error) {
console.error(`🚨 [${this.id}] Error parsing API response:`, error);
}
}, 5000);
console.log(`✅ [${this.id}] Navigation setup complete.`);
} catch (error) {
console.error(`❌ [${this.id}] Error during navigationnnnn:`, error);
}
}
async gotoLink() {
const page = this.page_context;
if (page.isClosed()) {
console.error(`❌ [${this.id}] Page has been closed, cannot navigate.`);
return;
}
console.log(`🔄 [${this.id}] Starting the bidding process...`);
try {
console.log(`🌐 [${this.id}] Navigating to: ${this.url} ...`);
await page.goto(this.url, { waitUntil: "networkidle2" });
console.log(`✅ [${this.id}] Successfully navigated to: ${this.url}`);
console.log(`🖥️ [${this.id}] Bringing tab to the foreground...`);
await page.bringToFront();
console.log(`🛠️ [${this.id}] Setting custom user agent...`);
await page.setUserAgent(
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
);
console.log(`🎯 [${this.id}] Listening for API responses...`);
// tracking out bid
this.trackingOutbid();
} catch (error) {
console.error(`❌ [${this.id}] Error during navigation:`, error);
}
}
action = async () => {
try {
const page = this.page_context;
// 📌 Kiểm tra nếu trang chưa tải đúng URL thì điều hướng đến URL mục tiêu
if (!page.url() || !page.url().includes(this.url)) {
console.log(`🔄 [${this.id}] Navigating to target URL: ${this.url}`);
await this.gotoLink();
}
await this.handlePlaceBid();
} catch (error) {
console.error(`🚨 [${this.id}] Error navigating the page: ${error}`);
}
};
}

View File

@ -1,159 +1,161 @@
import * as fs from 'fs'; 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 { getPathProfile } from "../system/utils.js";
import CONSTANTS from '../system/constants.js'; import { Bid } from "./bid.js";
import { delay, getPathProfile, sanitizeFileName } from '../system/utils.js';
import { Bid } from './bid.js';
export class ProductBid extends Bid { export class ProductBid extends Bid {
id; id;
max_price; max_price;
model; model;
lot_id; lot_id;
plus_price; plus_price;
close_time; close_time;
first_bid; first_bid;
quantity; quantity;
created_at; created_at;
updated_at; updated_at;
histories; histories;
start_bid_time; start_bid_time;
parent_browser_context; parent_browser_context;
web_bid; web_bid;
current_price; current_price;
name; name;
reserve_price; reserve_price;
update; update;
constructor({ constructor({
url, url,
max_price, max_price,
plus_price, plus_price,
model, model,
first_bid = false, first_bid = false,
id, id,
created_at, created_at,
updated_at, updated_at,
quantity = 1, quantity = 1,
histories = [], histories = [],
close_time, close_time,
lot_id, lot_id,
start_bid_time, start_bid_time,
web_bid, web_bid,
current_price, current_price,
reserve_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.plus_price = plus_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;
this.updated_at = updated_at; this.updated_at = updated_at;
this.quantity = quantity; this.quantity = quantity;
this.histories = histories; this.histories = histories;
this.close_time = close_time; this.close_time = close_time;
this.lot_id = lot_id; this.lot_id = lot_id;
this.start_bid_time = start_bid_time; this.start_bid_time = start_bid_time;
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; this.reserve_price = reserve_price;
}
setNewData({
url,
max_price,
plus_price,
model,
first_bid = false,
id,
created_at,
updated_at,
quantity = 1,
histories = [],
close_time,
lot_id,
start_bid_time,
web_bid,
current_price,
reserve_price,
name,
}) {
this.max_price = max_price || 0;
this.model = model;
this.plus_price = plus_price || 0;
this.first_bid = first_bid;
this.id = id;
this.created_at = created_at;
this.updated_at = updated_at;
this.quantity = quantity;
this.histories = histories;
this.close_time = close_time;
this.lot_id = lot_id;
this.start_bid_time = start_bid_time;
this.web_bid = web_bid;
this.url = url;
this.current_price = current_price;
this.name = name;
this.reserve_price = reserve_price;
}
puppeteer_connect = async () => {
if (!this.parent_browser_context) {
console.log(
`❌ Connect fail. parent_browser_context is null: ${this.id}`
);
return;
} }
setNewData({ const context = await browser.createBrowserContext();
url,
max_price, const statusInit = await this.restoreContext(context);
plus_price,
model, if (!statusInit) {
first_bid = false, console.log(`⚠️ Restore failed.`);
id, return;
created_at,
updated_at,
quantity = 1,
histories = [],
close_time,
lot_id,
start_bid_time,
web_bid,
current_price,
reserve_price,
name,
}) {
this.max_price = max_price || 0;
this.model = model;
this.plus_price = plus_price || 0;
this.first_bid = first_bid;
this.id = id;
this.created_at = created_at;
this.updated_at = updated_at;
this.quantity = quantity;
this.histories = histories;
this.close_time = close_time;
this.lot_id = lot_id;
this.start_bid_time = start_bid_time;
this.web_bid = web_bid;
this.url = url;
this.current_price = current_price;
this.name = name;
this.reserve_price = reserve_price;
} }
puppeteer_connect = async () => { const page = await context.newPage();
if (!this.parent_browser_context) {
console.log(`❌ Connect fail. parent_browser_context is null: ${this.id}`);
return;
}
const context = await browser.createBrowserContext(); this.page_context = page;
};
const statusInit = await this.restoreContext(context); async restoreContext(context) {
const filePath = getPathProfile(this.web_bid.origin_url);
if (!statusInit) { if (!fs.existsSync(filePath)) return false;
console.log(`⚠️ Restore failed.`);
return;
}
const page = await context.newPage(); const contextData = JSON.parse(fs.readFileSync(filePath, "utf8"));
this.page_context = page; // Restore Cookies
}; await context.setCookie(...contextData.cookies);
async restoreContext(context) { return true;
const filePath = getPathProfile(this.web_bid.origin_url); }
if (!fs.existsSync(filePath)) return false; async gotoLink() {
const page = this.page_context;
const contextData = JSON.parse(fs.readFileSync(filePath, 'utf8')); if (page.isClosed()) {
console.error("❌ Page has been closed, cannot navigate.");
// Restore Cookies return;
await context.setCookie(...contextData.cookies);
return true;
} }
async gotoLink() { console.log("🔄 Starting the bidding process...");
const page = this.page_context;
if (page.isClosed()) { try {
console.error('❌ Page has been closed, cannot navigate.'); await page.goto(this.url, { waitUntil: "networkidle2" });
return; console.log(`✅ Navigated to: ${this.url}`);
}
console.log('🔄 Starting the bidding process...'); await page.bringToFront();
await page.setUserAgent(
try { "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
await page.goto(this.url, { waitUntil: 'networkidle2' }); );
console.log(`✅ Navigated to: ${this.url}`); console.log("👀 Brought the tab to the foreground.");
} catch (error) {
await page.bringToFront(); console.error("❌ Error during navigation:", error);
await page.setUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36');
console.log('👀 Brought the tab to the foreground.');
} catch (error) {
console.error('❌ Error during navigation:', error);
}
} }
}
} }

View File

@ -1,70 +1,82 @@
import * as fs from 'fs'; import * as fs from "fs";
import path from 'path'; import path from "path";
import { GrayApiBid } from '../models/grays.com/grays-api-bid.js'; import { GrayApiBid } from "../models/grays.com/grays-api-bid.js";
import { GraysProductBid } from '../models/grays.com/grays-product-bid.js'; import { GraysProductBid } from "../models/grays.com/grays-product-bid.js";
import { LangtonsApiBid } from '../models/langtons.com.au/langtons-api-bid.js'; import { LangtonsApiBid } from "../models/langtons.com.au/langtons-api-bid.js";
import { LangtonsProductBid } from '../models/langtons.com.au/langtons-product-bid.js'; import { LangtonsProductBid } from "../models/langtons.com.au/langtons-product-bid.js";
import configs from '../system/config.js'; import { LawsonsApiBid } from "../models/lawsons.com.au/lawsons-api-bid.js";
import CONSTANTS from '../system/constants.js'; import { LawsonsProductBid } from "../models/lawsons.com.au/lawsons-product-bid.js";
import { sanitizeFileName } from '../system/utils.js'; import { PicklesApiBid } from "../models/pickles.com.au/pickles-api-bid.js";
import { LawsonsApiBid } from '../models/lawsons.com.au/lawsons-api-bid.js'; import { PicklesProductBid } from "../models/pickles.com.au/pickles-product-bid.js";
import { LawsonsProductBid } from '../models/lawsons.com.au/lawsons-product-bid.js'; import configs from "../system/config.js";
import CONSTANTS from "../system/constants.js";
import { sanitizeFileName } from "../system/utils.js";
// Time to update
const TIME = 30 * 1000; const TIME = 30 * 1000;
export const handleCloseRemoveProduct = (data) => { export const handleCloseRemoveProduct = (data) => {
if (!Array.isArray(data)) return; if (!Array.isArray(data)) return;
data.forEach(async (item) => { data.forEach(async (item) => {
if (item.page_context) { if (item.page_context) {
safeClosePage(item); safeClosePage(item);
} }
}); });
}; };
export const createBidProduct = (web, data) => { export const createBidProduct = (web, data) => {
switch (web.origin_url) { switch (web.origin_url) {
case configs.WEB_URLS.GRAYS: { case configs.WEB_URLS.GRAYS: {
return new GraysProductBid({ ...data }); return new GraysProductBid({ ...data });
}
case configs.WEB_URLS.LANGTONS: {
return new LangtonsProductBid({ ...data });
}
case configs.WEB_URLS.LAWSONS: {
return new LawsonsProductBid({ ...data });
}
} }
case configs.WEB_URLS.LANGTONS: {
return new LangtonsProductBid({ ...data });
}
case configs.WEB_URLS.LAWSONS: {
return new LawsonsProductBid({ ...data });
}
case configs.WEB_URLS.PICKLES: {
return new PicklesProductBid({ ...data });
}
}
}; };
export const createApiBid = (web) => { export const createApiBid = (web) => {
switch (web.origin_url) { switch (web.origin_url) {
case configs.WEB_URLS.GRAYS: { case configs.WEB_URLS.GRAYS: {
return new GrayApiBid({ ...web }); return new GrayApiBid({ ...web });
}
case configs.WEB_URLS.LANGTONS: {
return new LangtonsApiBid({ ...web });
}
case configs.WEB_URLS.LAWSONS: {
return new LawsonsApiBid({ ...web });
}
} }
case configs.WEB_URLS.LANGTONS: {
return new LangtonsApiBid({ ...web });
}
case configs.WEB_URLS.LAWSONS: {
return new LawsonsApiBid({ ...web });
}
case configs.WEB_URLS.PICKLES: {
return new PicklesApiBid({ ...web });
}
}
}; };
export const deleteProfile = (data) => { export const deleteProfile = (data) => {
if (!data?.origin_url) return false; if (!data?.origin_url) return false;
const filePath = path.join(CONSTANTS.PROFILE_PATH, sanitizeFileName(data?.origin_url) + '.json'); const filePath = path.join(
CONSTANTS.PROFILE_PATH,
sanitizeFileName(data?.origin_url) + ".json"
);
if (fs.existsSync(filePath)) { if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath); fs.unlinkSync(filePath);
return true; return true;
} }
return false; return false;
}; };
export const shouldUpdateProductTab = (productTab) => { export const shouldUpdateProductTab = (productTab) => {
const updatedAt = new Date(productTab.updated_at).getTime(); const updatedAt = new Date(productTab.updated_at).getTime();
const now = Date.now(); const now = Date.now();
return now - updatedAt >= TIME; return now - updatedAt >= TIME;
}; };

View File

@ -1,125 +1,143 @@
import axios from '../axios.js'; import axios from "../axios.js";
import fs from 'fs'; import fs from "fs";
export const getBids = async () => { export const getBids = async () => {
try { try {
const { data } = await axios({ const { data } = await axios({
method: 'GET', method: "GET",
url: 'bids', url: "bids",
}); });
if (!data || !data?.data) { if (!data || !data?.data) {
console.log('❌ DATA IS NOT FOUND ON SERVER'); console.log("❌ DATA IS NOT FOUND ON SERVER");
return []; return [];
}
return data.data;
} catch (error) {
console.log('❌ ERROR IN SERVER (GET BIDS): ', error);
return [];
} }
return data.data;
} catch (error) {
console.log("❌ ERROR IN SERVER (GET BIDS): ", error);
return [];
}
}; };
export const updateBid = async (id, values) => { export const updateBid = async (id, values) => {
try { try {
const { data } = await axios({ const { data } = await axios({
method: 'PUT', method: "PUT",
url: 'bids/' + id, url: "bids/" + id,
data: values, data: values,
}); });
if (!data || !data?.data) { if (!data || !data?.data) {
console.log('❌ UPDATE FAILURE (UPDATE BID)'); console.log("❌ UPDATE FAILURE (UPDATE BID)");
return null; return null;
}
return data.data;
} catch (error) {
console.log('❌ ERROR IN SERVER: (UPDATE BID) ', error.response);
return null;
} }
return data.data;
} catch (error) {
console.log("❌ ERROR IN SERVER: (UPDATE BID) ", error.response);
return null;
}
}; };
export const outBid = async (id) => { export const outBid = async (id) => {
try { try {
const { data } = await axios({ const { data } = await axios({
method: 'POST', method: "POST",
url: 'bids/out-bid/' + id, url: "bids/out-bid/" + id,
}); });
if (!data || !data?.data) { if (!data || !data?.data) {
console.log('❌ OUT BID UPDATE FAILURE'); console.log("❌ OUT BID UPDATE FAILURE");
return false; return false;
}
return data.data;
} catch (error) {
console.log('❌ ERROR IN SERVER (OUT BID UPDATE): ', error);
return false;
} }
return data.data;
} catch (error) {
console.log("❌ ERROR IN SERVER (OUT BID UPDATE): ", error);
return false;
}
}; };
export const pushPrice = async (values) => { export const pushPrice = async (values) => {
try { try {
const { data } = await axios({ const { data } = await axios({
method: 'POST', method: "POST",
url: 'bid-histories', url: "bid-histories",
data: values, data: values,
}); });
if (!data || !data?.data) { if (!data || !data?.data) {
console.log('❌ PUSH PRICE FAILURE'); console.log("❌ PUSH PRICE FAILURE");
return { status: false, data: [] }; return { status: false, data: [] };
}
return { status: true, data: data.data };
} catch (error) {
console.log('❌ ERROR IN SERVER (PUSH PRICE): ', error?.response);
return { status: false, data: [] };
} }
return { status: true, data: data.data };
} catch (error) {
console.log("❌ ERROR IN SERVER (PUSH PRICE): ", error?.response);
return { status: false, data: [] };
}
}; };
export const updateStatusByPrice = async (id, current_price) => { export const updateStatusByPrice = async (id, current_price) => {
try { try {
const { data } = await axios({ const { data } = await axios({
method: 'POST', method: "POST",
url: 'bids/update-status/' + id, url: "bids/update-status/" + id,
data: { data: {
current_price: Number(current_price) | 0, current_price: Number(current_price) | 0,
}, },
}); });
if (!data || !data?.data) { if (!data || !data?.data) {
console.log('❌ UPDATE STATUS BY PRICE FAILURE'); console.log("❌ UPDATE STATUS BY PRICE FAILURE");
return { status: false, data: [] }; return { status: false, data: [] };
}
return { status: true, data: data.data };
} catch (error) {
console.log('❌ ERROR IN SERVER:(UPDATE STATUS BY PRICE) ', {
// response: error.response,
message: error.message,
});
return { status: false, data: [] };
} }
return { status: true, data: data.data };
} catch (error) {
console.log("❌ ERROR IN SERVER:(UPDATE STATUS BY PRICE) ", {
// response: error.response,
message: error.message,
});
return { status: false, data: [] };
}
}; };
export const updateStatusWork = async (item, filePath) => { export const updateStatusWork = async (item, filePath) => {
try { try {
const response = await axios({ const response = await axios({
method: 'POST', method: "POST",
headers: { headers: {
'Content-Type': 'multipart/form-data', "Content-Type": "multipart/form-data",
}, },
url: `bids/update-status-work/${item.type}/${item.id}`, url: `bids/update-status-work/${item.type}/${item.id}`,
data: { data: {
image: fs.createReadStream(filePath), image: fs.createReadStream(filePath),
}, },
}); });
fs.unlinkSync(filePath); fs.unlinkSync(filePath);
return response.data?.data; return response.data?.data;
} catch (error) { } catch (error) {
console.error('❌ Upload failed:', error.response?.data || error.message); console.error("❌ Upload failed:", error.response?.data || error.message);
return false; return false;
} }
};
export const updateLoginStatus = async (data) => {
try {
const response = await axios({
method: "POST",
url: `bids/update-login-status`,
data,
});
return response.data?.data;
} catch (error) {
console.error(
"❌ UPDATE FAILURE (LOGIN STATUS):",
error.response?.data || error.message
);
return false;
}
}; };

View File

@ -1,21 +1,21 @@
import axios from '../axios.js'; import axios from "../axios.js";
export const sendMessage = async (values) => { export const sendMessage = async (values) => {
try { try {
const { data } = await axios({ const { data } = await axios({
method: 'POST', method: "POST",
url: 'notifications/send-messages', url: "notifications/send-messages",
data: values, data: values,
}); });
if (!data || !data?.data) { if (!data || !data?.data) {
console.log('❌ UPDATE FAILURE (UPDATE Noti)'); console.log("❌ UPDATE FAILURE (UPDATE Noti)");
return null; return null;
}
return data.data;
} catch (error) {
console.log('❌ ERROR IN SERVER: (UPDATE Noti) ', error);
return null;
} }
return data.data;
} catch (error) {
console.log("❌ ERROR IN SERVER: (UPDATE Noti) ", error.response);
return null;
}
}; };

View File

@ -1,34 +1,45 @@
const configs = { const configs = {
AUTO_TRACKING_DELAY: 5000, AUTO_TRACKING_DELAY: 5000,
AUTO_TRACKING_CLEANING: 10000, AUTO_TRACKING_CLEANING: 10000,
SOCKET_URL: process.env.SOCKET_URL, SOCKET_URL: process.env.SOCKET_URL,
WEB_URLS: { WEB_URLS: {
GRAYS: `https://www.grays.com`, GRAYS: `https://www.grays.com`,
LANGTONS: `https://www.langtons.com.au`, LANGTONS: `https://www.langtons.com.au`,
LAWSONS: `https://www.lawsons.com.au`, LAWSONS: `https://www.lawsons.com.au`,
PICKLES: `https://www.pickles.com.au`,
},
WEB_CONFIGS: {
GRAYS: {
AUTO_CALL_API_TO_TRACKING: 3000,
API_CALL_TO_TRACKING:
"https://www.grays.com/api/Notifications/GetOutBidLots",
}, },
WEB_CONFIGS: { LANGTONS: {
GRAYS: { AUTO_CALL_API_TO_TRACKING: 5000,
AUTO_CALL_API_TO_TRACKING: 3000, LOGIN_URL: "https://www.langtons.com.au/account/login",
API_CALL_TO_TRACKING: 'https://www.grays.com/api/Notifications/GetOutBidLots', API_CALL_TO_TRACKING:
}, "https://www.langtons.com.au/on/demandware.store/Sites-langtons-Site/en_AU/Auction-LotsData",
LANGTONS: {
AUTO_CALL_API_TO_TRACKING: 5000,
LOGIN_URL: 'https://www.langtons.com.au/account/login',
API_CALL_TO_TRACKING: 'https://www.langtons.com.au/on/demandware.store/Sites-langtons-Site/en_AU/Auction-LotsData',
},
LAWSONS: {
LOGIN_URL: 'https://www.lawsons.com.au/login?redirectUrl=/my-account/current-bids',
// API_CALL_TO_TRACKING: 'https://www.langtons.com.au/on/demandware.store/Sites-langtons-Site/en_AU/Auction-LotsData',
API_DETAIL_INFO: (model) => {
return `https://www.lawsons.com.au/api/auctions/lot/v2/liveInfo/${model}`;
},
API_DETAIL_PRODUCT: (model) => {
return `https://www.lawsons.com.au/api/auctions/lot/${model}`;
},
API_CHECKOUT: 'https://www.lawsons.com.au/app/orderBid',
},
}, },
LAWSONS: {
LOGIN_URL:
"https://www.lawsons.com.au/login?redirectUrl=/my-account/current-bids",
API_DETAIL_INFO: (model) => {
return `https://www.lawsons.com.au/api/auctions/lot/v2/liveInfo/${model}`;
},
API_DETAIL_PRODUCT: (model) => {
return `https://www.lawsons.com.au/api/auctions/lot/${model}`;
},
API_CHECKOUT: "https://www.lawsons.com.au/app/orderBid",
},
PICKLES: {
LOGIN_URL: "https://www.pickles.com.au/sign-in",
API_DETAIL_PRODUCT: (model) => {
return `https://www.pickles.com.au/api-website/buyer/ms-web-asset-aggregate/v2/api/assets/${model}/wap-item-details`;
},
API_CHECKOUT:
"https://www.pickles.com.au/delegate/secured/bidding/confirm",
},
},
}; };
export default configs; export default configs;