This commit is contained in:
nkhangg 2025-04-11 11:11:51 +07:00
parent 88bf1cabb6
commit 7f2083f25b
49 changed files with 2211 additions and 180 deletions

View File

@ -1,6 +1,6 @@
import { generateNestParams, handleError, handleSuccess } from '.';
import axios from '../lib/axios';
import { IBid } from '../system/type';
import { IBid, IWebBid } from '../system/type';
import { removeFalsyValues } from '../utils';
export const getBids = async (params: Record<string, string | number>) => {
@ -104,3 +104,17 @@ export const deletesBid = async (bids: IBid[]) => {
handleError(error);
}
};
export const getImagesWorking = async (values: (IBid | IWebBid) & { type: string }) => {
try {
const { data } = await axios({
url: `bids/images-working/${values.type.toLocaleLowerCase().replace('_', '-')}/${values.id}`,
withCredentials: true,
method: 'GET',
});
return data;
} catch (error) {
handleError(error);
}
};

View File

@ -3,6 +3,7 @@ import { useDisclosure } from '@mantine/hooks';
import moment from 'moment';
import { useEffect, useState } from 'react';
import { Socket } from 'socket.io-client';
import { getImagesWorking } from '../../apis/bid';
import { IBid, IWebBid } from '../../system/type';
import ShowImageModal from './show-image-modal';
@ -16,19 +17,27 @@ export default function WorkingPage({ data, socket }: IWorkingPageProps) {
const [opened, { open, close }] = useDisclosure(false);
const [imageSrc, setImageSrc] = useState(`${import.meta.env.VITE_BASE_URL}bids/status-working/${data.type.replace('_', '-').toLowerCase()}/${data.id}/working`);
const [imageSrc, setImageSrc] = useState<string | null>(null);
const [lastUpdate, setLastUpdate] = useState(new Date());
const [lastUpdate, setLastUpdate] = useState<Date | null>(null);
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;
};
useEffect(() => {
const updateImage = ({ type, id, filename }: { type: string; filename: string; id: IBid['id'] }) => {
if (type == data.type && id == data.id) {
setLastUpdate(new Date());
setImageSrc(`${import.meta.env.VITE_BASE_URL}bids/status-working/${type.replace('_', '-').toLowerCase()}/${id}/${filename}`);
setLastUpdate(new Date(extractTime(filename)));
setImageSrc(renderUrl(data, filename));
}
};
@ -37,8 +46,23 @@ export default function WorkingPage({ data, socket }: IWorkingPageProps) {
return () => {
socket.off('working', updateImage);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [socket, data.id, data.type]);
useEffect(() => {
(async () => {
const result = await getImagesWorking(data);
if (!result || !result.data) return;
const filename = result.data[0];
setImageSrc(renderUrl(data, filename));
setLastUpdate(new Date(extractTime(filename)));
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<>
<Box className="rounded-md overflow-hidden relative shadow-lg">
@ -74,7 +98,7 @@ export default function WorkingPage({ data, socket }: IWorkingPageProps) {
</Box>
</Box>
<ShowImageModal src={imageSrc} fallbackSrc={fallbackSrc} opened={opened} onClose={close} />
<ShowImageModal src={imageSrc || fallbackSrc} fallbackSrc={fallbackSrc} opened={opened} onClose={close} />
</>
);
}

View File

@ -243,6 +243,7 @@ export default function Bids() {
>
Histories
</Menu.Item>
{['https://www.grays.com'].includes(row?.web_bid.origin_url) && (
<Menu.Item
onClick={() => {
setClickData(row);
@ -252,6 +253,7 @@ export default function Bids() {
>
Bids
</Menu.Item>
)}
<Menu.Item
disabled={row.status === 'win-bid'}

View File

@ -34,6 +34,7 @@ export interface IBid extends ITimestamp {
first_bid: boolean;
status: 'biding' | 'out-bid' | 'win-bid';
histories: IHistory[];
web_bid: IWebBid;
}
export interface IHistory extends ITimestamp {

3
auto-bid-server/.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"cSpell.words": ["LANGTONS", "LAWSONS"]
}

178
auto-bid-server/a.html Normal file
View File

@ -0,0 +1,178 @@
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN">
<html>
<head>
<meta http-equiv=3D"Content-Type" content=3D"text/html; charset=3Dutf-8">
<title>Callcentric Email</title>
<style type=3D"text/css" media=3D"all">
body {
font-family: arial,helvetica,sans-serif;
font-size: 14px;
line-height: 22px;
color: #444;
margin: 0;
padding: 20px 0;
background-color: #e5e5e5;
outline: 0;
vertical-align: top;
}
p {
margin-bottom: 1.5em;
}
img {
display: block;
}
a:link,
a:visited {
color: #006699;
text-decoration: underline;
margin: 0px;
padding: 0px;
}
a:hover {=20
text-decoration: none;
}
li {
margin-bottom: 1em;
line-height: 22px;
}=09
=2Efooter a {
color: #aaaaaa;
text-decoration: underline;
}
=2Efooter a:hover {
text-decoration: none;
}
=2Eframe {
width: 580px;
}
#btn {
background: #3498db;
background-image: -webkit-linear-gradient(top, #3498db, #2980b9);
background-image: -moz-linear-gradient(top, #3498db, #2980b9);
background-image: -ms-linear-gradient(top, #3498db, #2980b9);
background-image: -o-linear-gradient(top, #3498db, #2980b9);
background-image: linear-gradient(to bottom, #3498db, #2980b9);
-webkit-border-radius: 28;
-moz-border-radius: 28;
border-radius: 28px;
font-family: Arial;
color: #ffffff;
font-size: 20px;
padding: 10px 20px 10px 20px;
text-decoration: none;
display: inline-block;
}
#btn:hover {
background: #3cb0fd;
background-image: -webkit-linear-gradient(top, #3cb0fd, #3498db);
background-image: -moz-linear-gradient(top, #3cb0fd, #3498db);
background-image: -ms-linear-gradient(top, #3cb0fd, #3498db);
background-image: -o-linear-gradient(top, #3cb0fd, #3498db);
background-image: linear-gradient(to bottom, #3cb0fd, #3498db);
text-decoration: none;
}
=2Esms-box { font-family: arial,helvetica,sans-serif; font-size: 14px; padd=
ing:12px; border:1px solid #e0e0e0; background-color: #e0e0e0; -webkit-bord=
er-radius: 12px; -moz-border-radius: 12px; border-radius: 12px; word-wrap:b=
reak-word; display:inline-block; }
p { font-family: arial,helvetica,sans-serif; font-size: 14px; margin-bottom=
: 1.5em; line-height: 22px; }
@media only screen and (max-device-width: 480px) {
.frame {
width: 442px !important;
}
p {
font-size: 22px !important;
line-height: 28px !important;
}
ul li {
font-size: 22px !important;
line-height: 28px !important;
}
.section_header{
font-size: 28px !important;
line-height: 30px !important;
}
#btn {
font-size: 28px !important;
padding: 14px 24px 14px 24px !important;
}
}
</style>
</head>
<body style=3D"font-family: Arial, sans-serif; font-size: 14px; margin-bott=
om: 1.5em; line-height: 22px; color: black; width: 100% !important; margin:=
0; padding: 0px; background-color: #e5e5e5; outline: 0; vertical-align: to=
p;" bgcolor=3D"#e5e5e5">
<table width=3D"100%" cellpadding=3D"0" cellspacing=3D"0" border=3D"0"><tr>=
<td style=3D"padding: 0px 0px;" align=3D"center">
<table class=3D"head" width=3D"100%" bgcolor=3D"#c9c9c9" style=3D"backgroun=
d-color:#c9c9c9;">
<tr><td align=3D"center" style=3D"color:#f3f3f3; padding:5px; font-size:12=
px;">Please do not respond to this automated message, you will not receive =
a reply</td></tr>
</table>
</td></tr><tr><td style=3D"padding: 0px 10px;" align=3D"center">
<table class=3D"frame" width=3D"580" cellpadding=3D"0" cellspacing=3D"0" bo=
rder=3D"0" align=3D"center"><tbody>
<tr><td>
<table width=3D"100%" cellpadding=3D"0" cellspacing=3D"0" border=3D"0">
<tr><td style=3D"padding: 30px 0px 10px 0px; color: #444; text-align: le=
ft; vertical-align: middle;" align=3D"left"><a href=3D"https://www.callcent=
ric.com"><img src=3D"https://www.callcentric.com/img/callcentric_logo_250_t=
rans.png" width=3D"125" height=3D"33" alt=3D"Callcentric" border=3D"0"></a>=
</td></tr>
</table>
</td></tr>
<tr><td style=3D"background-color: #ffffff; padding: 0px 0px 50px; border-=
radius: 5px 5px 0px 0px;">
<table style=3D"padding-top: 40px;" width=3D"100%" cellpadding=3D"0" cell=
spacing=3D"0" border=3D"0"><tbody>
<tr><td class=3D"flex-column" style=3D"width: 40px;"></td>
<td style=3D"text-align: left;" align=3D"left">
<p>Dear Morgan Vu,</p><p>You have received an inbound text message from 223=
95 on your DID 17146893350:</p><div class=3D"sms-box">Your LANGTONS verific=
ation code is: 012152. This code will expire in 10 minutes.</div>
</td><td class=3D"flex-column" style=3D"width: 40px;"></td></tr>
</tbody></table>
</td></tr>
<tr><td align=3D"left" class=3D"footer" style=3D"background-color: #f2f2f2=
; color: #999999; padding: 0px 0px 40px; border-radius: 0px 0px 5px 5px;">
<table border=3D"0" cellpadding=3D"0" cellspacing=3D"0" style=3D"padding-=
top: 40px;" width=3D"100%"><tbody>
<tr><td style=3D"width: 40px;"></td>
<td align=3D"left" style=3D"text-align: left; font-family:Arial, Helvet=
ica, sans-serif; font-size: 15px; line-height: 22px; color: #999999;">
Follow us on Twitter at:<br>
<a href=3D"https://twitter.com/Callcentric">https://twitter.com/Callcentric=
</a>
<br><br>
If you have any questions/concerns, please contact our Customer Service Tea=
m via the following page:<br>
<a href=3D"https://www.callcentric.com/CustomerService">https://www.callcen=
tric.com/CustomerService</a><br><br>
For instructions on how to unsubscribe from our Newsletters/Service Announc=
ements please visit:<br>
<a href=3D"https://www.callcentric.com/faq/11/306">https://www.callcentric=
=2Ecom/faq/11/306</a><br><br>
<a href=3D"https://www.callcentric.com">www.callcentric.com</a><br><br>
</td><td style=3D"width: 40px;"></td>
</tr>
</tbody></table>
</td></tr>
<tr><td style=3D"padding: 20px 0; font-size: 15px; line-height: 25px; colo=
r: #999999; text-align: center;" align=3D"center"></td></tr>
</tbody></table></td></tr></table>
</body>
</html>

View File

@ -21,6 +21,12 @@
"can_manage_topics": true
},
"join_to_send_messages": true,
"accepted_gift_types": {
"unlimited_gifts": false,
"limited_gifts": false,
"unique_gifts": false,
"premium_subscription": false
},
"max_reaction_count": 11,
"accent_color_id": 2
}

View File

@ -1 +1 @@
{"createdAt":1743133400720}
{"createdAt":1744342271961}

View File

@ -27,12 +27,15 @@
"cookie": "^1.0.2",
"cookie-parser": "^1.4.7",
"dayjs": "^1.11.13",
"imap": "^0.8.19",
"lodash": "^4.17.21",
"multer": "^1.4.5-lts.1",
"mysql2": "^3.13.0",
"nestjs-paginate": "^11.1.0",
"reflect-metadata": "^0.2.0",
"rxjs": "^7.8.1",
"sharp": "^0.33.5",
"simple-imap": "^0.1.6",
"typeorm": "^0.3.21",
"uuid": "^11.1.0"
},
@ -44,7 +47,9 @@
"@types/cookie": "^0.6.0",
"@types/cookie-parser": "^1.4.8",
"@types/express": "^4.17.17",
"@types/imap": "^0.8.42",
"@types/jest": "^29.5.2",
"@types/lodash": "^4.17.16",
"@types/multer": "^1.4.12",
"@types/node": "^20.3.1",
"@types/supertest": "^6.0.0",
@ -2931,6 +2936,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/imap": {
"version": "0.8.42",
"resolved": "https://registry.npmjs.org/@types/imap/-/imap-0.8.42.tgz",
"integrity": "sha512-FusePG9Cp2GYN6OLow9xBCkjznFkAR7WCz0Fm+j1p/ER6C8V8P71DtjpSmwrZsS7zekCeqdTPHEk9N5OgPwcsg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/istanbul-lib-coverage": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
@ -2985,6 +3000,13 @@
"@types/node": "*"
}
},
"node_modules/@types/lodash": {
"version": "4.17.16",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.16.tgz",
"integrity": "sha512-HX7Em5NYQAXKW+1T+FiuG27NGwzJfCX3s1GjOa7ujxZa52kjJLOr4FUxT+giF6Tgxv1e+/czV/iTtBw27WTU9g==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/methods": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz",
@ -3558,6 +3580,12 @@
"node": ">=0.4.0"
}
},
"node_modules/addressparser": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/addressparser/-/addressparser-1.0.1.tgz",
"integrity": "sha512-aQX7AISOMM7HFE0iZ3+YnD07oIeJqWGVnJ+ZIKaBZAk03ftmVYVqsGas/rbXKR21n4D/hKCSHypvcyOkds/xzg==",
"license": "MIT"
},
"node_modules/agent-base": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
@ -5189,6 +5217,27 @@
"node": ">= 0.8"
}
},
"node_modules/encoding": {
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz",
"integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==",
"license": "MIT",
"dependencies": {
"iconv-lite": "^0.6.2"
}
},
"node_modules/encoding/node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/engine.io": {
"version": "6.6.4",
"resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.4.tgz",
@ -6771,6 +6820,42 @@
"node": ">= 4"
}
},
"node_modules/imap": {
"version": "0.8.19",
"resolved": "https://registry.npmjs.org/imap/-/imap-0.8.19.tgz",
"integrity": "sha512-z5DxEA1uRnZG73UcPA4ES5NSCGnPuuouUx43OPX7KZx1yzq3N8/vx2mtXEShT5inxB3pRgnfG1hijfu7XN2YMw==",
"dependencies": {
"readable-stream": "1.1.x",
"utf7": ">=1.0.2"
},
"engines": {
"node": ">=0.8.0"
}
},
"node_modules/imap/node_modules/isarray": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz",
"integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==",
"license": "MIT"
},
"node_modules/imap/node_modules/readable-stream": {
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz",
"integrity": "sha512-+MeVjFf4L44XUkhM1eYbD8fyEsxcV81pqMSR5gblfcLCHfZvbrqy4/qYHE+/R5HoBUT11WV5O08Cr1n3YXkWVQ==",
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.1",
"isarray": "0.0.1",
"string_decoder": "~0.10.x"
}
},
"node_modules/imap/node_modules/string_decoder": {
"version": "0.10.31",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz",
"integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==",
"license": "MIT"
},
"node_modules/import-fresh": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@ -8263,6 +8348,18 @@
"node": ">=12"
}
},
"node_modules/mailparser": {
"version": "0.4.6",
"resolved": "https://registry.npmjs.org/mailparser/-/mailparser-0.4.6.tgz",
"integrity": "sha512-G9D9e+v0UKpG7jiANGNkMCinqcG+Q70GngQQm8WknkMEFLx7Cyp+vnoAJaOtVCR9dPuyVAKaUJlOgDE6HTwmSw==",
"deprecated": "Mailparser versions older than v2.3.0 are deprecated",
"dependencies": {
"encoding": ">=0.1.4",
"mime": "*",
"mimelib": ">=0.2.17",
"uue": "~1.0.0"
}
},
"node_modules/make-dir": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
@ -8422,6 +8519,17 @@
"node": ">= 0.6"
}
},
"node_modules/mimelib": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/mimelib/-/mimelib-0.3.1.tgz",
"integrity": "sha512-ipzNp6TBsNfD3hButGlPVlGmuCgybIM9SBf8YwIG+SYmBgtU0u8wjf+BSrJX0mvqtv59SLmwphw/XiCbkLWv7w==",
"deprecated": "This project is unmaintained",
"license": "EUPL-1.1",
"dependencies": {
"addressparser": "~1.0.1",
"encoding": "~0.1.12"
}
},
"node_modules/mimic-fn": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz",
@ -8509,6 +8617,14 @@
"mkdirp": "bin/cmd.js"
}
},
"node_modules/moment": {
"version": "2.8.3",
"resolved": "https://registry.npmjs.org/moment/-/moment-2.8.3.tgz",
"integrity": "sha512-BV9tebhfgcO0mT3desIQEM8vdFGOsqwfJ71a+mz/ECVa0uiokosBq3A3Vyk7XhyNUzzQECX/T/bpHQANemf5Vg==",
"engines": {
"node": "*"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@ -10128,6 +10244,64 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/simple-imap": {
"version": "0.1.6",
"resolved": "https://registry.npmjs.org/simple-imap/-/simple-imap-0.1.6.tgz",
"integrity": "sha512-mdmaq1u/qEOa8Ow3ZPKv2KXZbV5oHfFewqgqi8FnxD0mQzMzMLtKcE3TEmI7qhp1FmRcdqBlYeUMtOt7nS7XkA==",
"license": "MIT",
"dependencies": {
"imap": "0.8.14",
"mailparser": "0.4.6",
"moment": "2.8.3"
},
"engines": {
"node": ">=0.8.0"
}
},
"node_modules/simple-imap/node_modules/imap": {
"version": "0.8.14",
"resolved": "https://registry.npmjs.org/imap/-/imap-0.8.14.tgz",
"integrity": "sha512-8opGClGwiZ7/NBc3wofk7PbGkaLqggXx8W0D/Bc6YQdufl9CixmMBKm5rrn9gyelMVdMQVfpXp3D6gdXBgtAdg==",
"dependencies": {
"readable-stream": "1.1.x",
"utf7": "1.0.0"
},
"engines": {
"node": ">=0.8.0"
}
},
"node_modules/simple-imap/node_modules/isarray": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz",
"integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==",
"license": "MIT"
},
"node_modules/simple-imap/node_modules/readable-stream": {
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz",
"integrity": "sha512-+MeVjFf4L44XUkhM1eYbD8fyEsxcV81pqMSR5gblfcLCHfZvbrqy4/qYHE+/R5HoBUT11WV5O08Cr1n3YXkWVQ==",
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.1",
"isarray": "0.0.1",
"string_decoder": "~0.10.x"
}
},
"node_modules/simple-imap/node_modules/string_decoder": {
"version": "0.10.31",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz",
"integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==",
"license": "MIT"
},
"node_modules/simple-imap/node_modules/utf7": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/utf7/-/utf7-1.0.0.tgz",
"integrity": "sha512-vMfAo9fTQeZG7W8PEOWa9g++rkHxYqBBrb22OWY+OMUnSFFtQURySIIJ2LstpF5JI6SzsGQNaglin2aLwA7pTA==",
"engines": {
"node": "*"
}
},
"node_modules/simple-swizzle": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz",
@ -11371,6 +11545,23 @@
"punycode": "^2.1.0"
}
},
"node_modules/utf7": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/utf7/-/utf7-1.0.2.tgz",
"integrity": "sha512-qQrPtYLLLl12NF4DrM9CvfkxkYI97xOb5dsnGZHE3teFr0tWiEZ9UdgMPczv24vl708cYMpe6mGXGHrotIp3Bw==",
"dependencies": {
"semver": "~5.3.0"
}
},
"node_modules/utf7/node_modules/semver": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-5.3.0.tgz",
"integrity": "sha512-mfmm3/H9+67MCVix1h+IXTpDwL6710LyHuk7+cWC9T1mE0qz4iHhh6r4hU2wrIT9iTsAAC2XQRvfblL028cpLw==",
"license": "ISC",
"bin": {
"semver": "bin/semver"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
@ -11386,6 +11577,11 @@
"node": ">= 0.4.0"
}
},
"node_modules/uue": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/uue/-/uue-1.0.0.tgz",
"integrity": "sha512-gEwvMeDsv0Q4SFsjhp/PmsXizTfEn3B8SXqtRiz5jE2nsRgEBCY5W5gmxOFrPVzbnDCELwOKyMnOEoL/W/TG7w=="
},
"node_modules/uuid": {
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",

View File

@ -43,12 +43,15 @@
"cookie": "^1.0.2",
"cookie-parser": "^1.4.7",
"dayjs": "^1.11.13",
"imap": "^0.8.19",
"lodash": "^4.17.21",
"multer": "^1.4.5-lts.1",
"mysql2": "^3.13.0",
"nestjs-paginate": "^11.1.0",
"reflect-metadata": "^0.2.0",
"rxjs": "^7.8.1",
"sharp": "^0.33.5",
"simple-imap": "^0.1.6",
"typeorm": "^0.3.21",
"uuid": "^11.1.0"
},
@ -60,7 +63,9 @@
"@types/cookie": "^0.6.0",
"@types/cookie-parser": "^1.4.8",
"@types/express": "^4.17.17",
"@types/imap": "^0.8.42",
"@types/jest": "^29.5.2",
"@types/lodash": "^4.17.16",
"@types/multer": "^1.4.12",
"@types/node": "^20.3.1",
"@types/supertest": "^6.0.0",

View File

@ -1,22 +1,11 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import axios from 'axios';
import { escapeMarkdownV2 } from 'src/ultils';
import { Bid } from '../entities/bid.entity';
import * as dayjs from 'dayjs';
import { SendMessageHistoriesService } from '../services/send-message-histories.service';
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
import { join } from 'path';
import { Bid } from '../entities/bid.entity';
import { Constant } from '../utils/constant';
import {
existsSync,
mkdir,
mkdirSync,
readdirSync,
readFileSync,
statSync,
writeFile,
writeFileSync,
} from 'fs';
@Injectable()
export class BotTelegramApi {

View File

@ -1,9 +1,13 @@
import { Module } from '@nestjs/common';
import { EventEmitterModule } from '@nestjs/event-emitter';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AdminsModule } from '../admins/admins.module';
import { NotificationModule } from '../notification/notification.module';
import { BotTelegramApi } from './apis/bot-telegram.api';
import { GraysApi } from './apis/grays.api';
import { AdminBidHistoriesController } from './controllers/admin/admin-bid-histories.controller';
import { AdminBidsController } from './controllers/admin/admin-bids.controller';
import { AdminOutBidLogsController } from './controllers/admin/admin-out-bid-logs.controller';
import { AdminSendMessageHistoriesController } from './controllers/admin/admin-send-message-histories.controller';
import { AdminWebBidsController } from './controllers/admin/admin-web-bids.controller';
import { BidHistoriesController } from './controllers/client/bid-histories.controller';
import { BidsController } from './controllers/client/bids.controller';
@ -11,21 +15,16 @@ import { OutBidLogsController } from './controllers/client/out-bid-logs.controll
import { BidHistory } from './entities/bid-history.entity';
import { Bid } from './entities/bid.entity';
import { OutBidLog } from './entities/out-bid-log.entity';
import { SendMessageHistory } from './entities/send-message-histories.entity';
import { WebBid } from './entities/wed-bid.entity';
import { AdminBidGateway } from './getways/admin-bid-getway';
import { BidGateway } from './getways/bid-getway';
import { BidHistoriesService } from './services/bid-histories.service';
import { BidsService } from './services/bids.service';
import { ImapService } from './services/mail/imap.service';
import { OutBidLogsService } from './services/out-bid-logs.service';
import { WebBidsService } from './services/web-bids.service';
import { BotTelegramApi } from './apis/bot-telegram.api';
import { GraysApi } from './apis/grays.api';
import { SendMessageHistory } from './entities/send-message-histories.entity';
import { SendMessageHistoriesService } from './services/send-message-histories.service';
import { AdminSendMessageHistoriesController } from './controllers/admin/admin-send-message-histories.controller';
import { AuthModule } from '../auth/auth.module';
import { AdminsModule } from '../admins/admins.module';
import { AdminBidGateway } from './getways/admin-bid-getway';
import { NotificationModule } from '../notification/notification.module';
import { WebBidsService } from './services/web-bids.service';
@Module({
imports: [
@ -60,7 +59,8 @@ import { NotificationModule } from '../notification/notification.module';
BotTelegramApi,
GraysApi,
SendMessageHistoriesService,
ImapService,
],
exports: [BotTelegramApi],
exports: [BotTelegramApi, SendMessageHistoriesService, BidsService],
})
export class BidsModule {}

View File

@ -59,4 +59,12 @@ export class AdminBidsController {
) {
return await this.bidsService.getStatusWorkingImage(id, type, name, res);
}
@Get('images-working/:type/:id')
async getImagesWorking(
@Param('id') id: Bid['id'],
@Param('type') type: string,
) {
return await this.bidsService.getImagesWorking(id, type);
}
}

View File

@ -5,22 +5,30 @@ import {
Param,
Post,
Put,
Res,
UploadedFile,
UseInterceptors,
} from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { FileInterceptor } from '@nestjs/platform-express';
import { plainToClass } from 'class-transformer';
import { memoryStorage } from 'multer';
import { ClientUpdateBidDto } from '../../dto/bid/client-update-bid.dto';
import { CreateBidDto } from '../../dto/bid/create-bid.dto';
import { Bid } from '../../entities/bid.entity';
import { BidsService } from '../../services/bids.service';
import { UpdateStatusByPriceDto } from '../../dto/bid/update-status-by-price.dto';
import { FileInterceptor } from '@nestjs/platform-express';
import { memoryStorage } from 'multer';
import { Response } from 'express';
import { Bid } from '../../entities/bid.entity';
import { WebBid } from '../../entities/wed-bid.entity';
import { BidsService } from '../../services/bids.service';
import { WebBidsService } from '../../services/web-bids.service';
import { Event } from '../../utils/events';
import AppResponse from '@/response/app-response';
@Controller('bids')
export class BidsController {
constructor(private readonly bidsService: BidsService) {}
constructor(
private readonly bidsService: BidsService,
private readonly eventEmitter: EventEmitter2,
private readonly webBidService: WebBidsService,
) {}
@Get()
index() {
@ -59,4 +67,21 @@ export class BidsController {
) {
return this.bidsService.updateStatusWork(id, type, image);
}
@Post('test')
async test(@Body('code') code: string) {
const webBid = await this.webBidService.webBidRepo.findOne({
// where: { id: 9 },
where: { id: 8 },
});
this.eventEmitter.emit(Event.verifyCode(webBid), {
code,
// name: 'LAWSONS',
name: 'LANGTONS',
web_bid: plainToClass(WebBid, webBid),
});
return AppResponse.toResponse({ code });
}
}

View File

@ -1,6 +1,7 @@
import { Body, Controller, Post } from '@nestjs/common';
import { CreateOutBidLogsDto } from '../../dto/out-bid-log/create-out-bid-logs.dto';
import { OutBidLogsService } from '../../services/out-bid-logs.service';
import { CreateOutBidLogDto } from '../../dto/out-bid-log/create-out-bid-log.dto';
@Controller('out-bid-logs')
export class OutBidLogsController {
@ -10,4 +11,9 @@ export class OutBidLogsController {
create(@Body() data: CreateOutBidLogsDto) {
return this.outBidLogsService.create(data.data);
}
@Post('simple')
createSimple(@Body() data: CreateOutBidLogDto) {
return this.outBidLogsService.createSimple(data);
}
}

View File

@ -5,6 +5,10 @@ export class ClientUpdateBidDto {
@IsOptional()
lot_id: string;
@IsString()
@IsOptional()
model: string;
@IsOptional()
@IsString()
close_time: string | null;

View File

@ -16,6 +16,6 @@ export class OutBidLog extends Timestamp {
@Column()
out_price: number;
@Column({ default: null, nullable: true })
@Column({ default: null, nullable: true, type: 'text' })
raw_data: string;
}

View File

@ -10,6 +10,9 @@ export class SendMessageHistory extends Timestamp {
@Column({ default: null, nullable: true, type: 'text' })
message: string;
@Column({ default: 'biding' })
type: string;
@ManyToOne(() => Bid, (bid) => bid.sendMessageHistories, {
onDelete: 'CASCADE',
})

View File

@ -11,6 +11,10 @@ import { plainToClass } from 'class-transformer';
import { Server, Socket } from 'socket.io';
import { WebBid } from '../entities/wed-bid.entity';
import { WebBidsService } from '../services/web-bids.service';
import * as Imap from 'imap';
import { ImapService } from '../services/mail/imap.service';
import { ConfigService } from '@nestjs/config';
import { Event } from '../utils/events';
@WebSocketGateway({
namespace: 'admin-bid-ws',
@ -29,16 +33,22 @@ export class AdminBidGateway implements OnGatewayConnection {
private readonly jwtService: JwtService,
private webBidsService: WebBidsService,
private readonly adminService: AdminsService,
) {}
private readonly imapService: ImapService,
) {
this.imapService.server = this.server;
}
async onModuleInit() {
this.eventEmitter.on('bids.updated', (data) => {
this.server.emit('adminBidsUpdated', plainToClass(WebBid, data));
this.eventEmitter.on(Event.BIDS_UPDATED, (data) => {
this.server.emit(Event.ADMIN_BIDS_UPDATED, plainToClass(WebBid, data));
});
this.eventEmitter.on('working', (data) => {
this.server.emit('working', data);
this.eventEmitter.on(Event.WORKING, (data) => {
this.server.emit(Event.WORKING, data);
});
// IMAP
this.imapService.connectIMAP();
}
async handleConnection(client: Socket) {
@ -47,7 +57,8 @@ export class AdminBidGateway implements OnGatewayConnection {
console.log(`📢 Client connected: ${client.id}`);
const data = await this.webBidsService.getDataClient();
// Gửi dữ liệu bids ngay khi client kết nối
client.emit('adminBidsUpdated', plainToClass(WebBid, data));
client.emit(Event.ADMIN_BIDS_UPDATED, plainToClass(WebBid, data));
}
}

View File

@ -9,6 +9,8 @@ import {
import { Server, Socket } from 'socket.io';
import { BidsService } from '../services/bids.service';
import { WebBidsService } from '../services/web-bids.service';
import { Event } from '../utils/events';
import { WebBid } from '../entities/wed-bid.entity';
@WebSocketGateway({
namespace: 'bid-ws',
@ -29,13 +31,24 @@ export class BidGateway implements OnGatewayConnection {
) {}
async onModuleInit() {
this.eventEmitter.on('bids.updated', (data) => {
this.server.emit('bidsUpdated', data);
this.eventEmitter.on(Event.BIDS_UPDATED, (data) => {
this.server.emit(Event.BIDS_UPDATED, data);
});
this.eventEmitter.on('web.updated', (data) => {
this.server.emit('webUpdated', data);
this.eventEmitter.on(Event.WEB_UPDATED, (data) => {
this.server.emit(Event.WEB_UPDATED, data);
});
this.eventEmitter.onAny(
(
event: string,
payload: { name: string; code: string; web_bid: WebBid },
) => {
if (!event.startsWith(Event.VERIFY_CODE)) return;
this.server.emit(Event.verifyCode(payload.web_bid), payload);
},
);
}
async handleConnection(client: Socket) {
@ -45,6 +58,6 @@ export class BidGateway implements OnGatewayConnection {
const data = await this.webBidsService.getDataClient();
// Gửi dữ liệu bids ngay khi client kết nối
client.emit('bidsUpdated', data);
client.emit(Event.BIDS_UPDATED, data);
}
}

View File

@ -14,17 +14,20 @@ import { CreateBidHistoryDto } from '../dto/bid-history/create-bid-history.dto';
import { BotTelegramApi } from '../apis/bot-telegram.api';
import { SendMessageHistoriesService } from './send-message-histories.service';
import { NotificationService } from '@/modules/notification/notification.service';
import { isTimeReached } from '@/ultils';
import { BidsService } from './bids.service';
@Injectable()
export class BidHistoriesService {
constructor(
@InjectRepository(BidHistory)
readonly bidHistoriesRepo: Repository<BidHistory>,
@InjectRepository(Bid)
readonly bidsRepo: Repository<Bid>,
// @InjectRepository(Bid)
// readonly bidsRepo: Repository<Bid>,
private readonly botTelegramApi: BotTelegramApi,
readonly sendMessageHistoriesService: SendMessageHistoriesService,
private readonly notificationService: NotificationService,
private readonly bidsService: BidsService,
) {}
async index() {
@ -32,8 +35,12 @@ export class BidHistoriesService {
}
async create({ price, bid_id }: CreateBidHistoryDto) {
const bid = await this.bidsRepo.findOne({ where: { id: bid_id } });
// Tìm thông tin bid từ database
const bid = await this.bidsService.bidsRepo.findOne({
where: { id: bid_id },
});
// Nếu không tìm thấy bid, trả về lỗi 404
if (!bid)
throw new NotFoundException(
AppResponse.toResponse(null, {
@ -42,23 +49,34 @@ export class BidHistoriesService {
}),
);
// Lấy lịch sử đặt giá cao nhất trước đó của bid hiện tại
const lastHistory = await this.bidHistoriesRepo.findOne({
where: { bid: { id: bid_id } },
order: { price: 'desc' },
});
// Nếu đã có lịch sử và giá mới giống với giá cao nhất hiện tại
if (lastHistory && lastHistory.price === price) {
// Nếu đã hết thời gian đấu giá, cập nhật trạng thái là 'win-bid'
if (isTimeReached(bid.close_time)) {
this.bidsService.bidsRepo.update(bid_id, { status: 'win-bid' });
}
// Ném lỗi không cho đặt giá trùng lặp
throw new BadRequestException(
AppResponse.toResponse(null, { message: 'Duplicate place bid' }),
);
}
// Nếu tổng giá vượt quá mức tối đa cho phép
if (price + bid.plus_price > bid.max_price) {
this.bidsRepo.update(bid_id, { status: 'out-bid' });
// Cập nhật trạng thái bid là 'out-bid'
this.bidsService.bidsRepo.update(bid_id, { status: 'out-bid' });
// send message event
// Gửi thông báo trạng thái mới qua service thông báo
this.notificationService.emitBidStatus({ ...bid, status: 'out-bid' });
// Ném lỗi không cho đặt giá vượt mức
throw new BadRequestException(
AppResponse.toResponse(null, {
message: 'Price is more than Max price ' + bid.max_price,
@ -66,8 +84,10 @@ export class BidHistoriesService {
);
}
// Lưu lịch sử đặt giá mới
await this.bidHistoriesRepo.save({ bid, price });
// Lấy danh sách tất cả lịch sử đặt giá theo thứ tự mới nhất
const response = await this.bidHistoriesRepo.find({
where: { bid: { id: bid_id } },
order: {
@ -75,19 +95,29 @@ export class BidHistoriesService {
},
});
// Nếu đây là lần đặt giá đầu tiên, cập nhật cờ `first_bid` thành false
if (response.length === 1) {
this.bidsRepo.update(bid_id, { first_bid: false });
this.bidsService.bidsRepo.update(bid_id, { first_bid: false });
}
// Gửi thông tin bid đến bot telegram
const botData = { ...bid, histories: response };
this.botTelegramApi.sendBidInfo(botData);
// Lưu message đã gửi để theo dõi
this.sendMessageHistoriesService.sendMessageRepo.save({
message: this.botTelegramApi.formatBidMessage(botData),
bid,
});
// Kiểm tra nếu trạng thái bid thay đổi sau khi lưu, phát sự kiện cập nhật tất cả bid
const bidUpdated = await this.bidsService.bidsRepo.findOne({
where: { id: bid_id },
});
this.bidsService.emitAllBidEvent();
// Trả về danh sách lịch sử đặt giá đã cập nhật
return AppResponse.toResponse(plainToClass(BidHistory, response));
}
}

View File

@ -30,6 +30,7 @@ import { ImageCompressionPipe } from '../pipes/image-compression-pipe';
import { Constant } from '../utils/constant';
import { WebBidsService } from './web-bids.service';
import { NotificationService } from '@/modules/notification/notification.service';
import { Event } from '../utils/events';
@Injectable()
export class BidsService {
@ -76,6 +77,7 @@ export class BidsService {
maxLimit: 100,
relations: {
histories: true,
web_bid: true,
},
});
@ -99,6 +101,7 @@ export class BidsService {
const model = extractModelId(data.url);
const prev = await this.bidsRepo.findOne({ where: { model: model } });
if (prev) {
throw new BadRequestException(
AppResponse.toResponse(null, { message: 'Product is already exits' }),
@ -197,10 +200,20 @@ export class BidsService {
async clientUpdate(
id: Bid['id'],
{ close_time, ...data }: ClientUpdateBidDto,
{ close_time, model, ...data }: ClientUpdateBidDto, // Nhận dữ liệu cập nhật
) {
const bid = await this.bidsRepo.findOne({ where: { id } });
// Tìm kiếm phiên đấu giá trong database theo id
const bid = await this.bidsRepo.findOne({
where: { id },
relations: { histories: true },
order: {
histories: {
price: 'DESC',
},
},
});
// Nếu không tìm thấy phiên đấu giá, trả về lỗi 404
if (!bid)
throw new NotFoundException(
AppResponse.toResponse(null, {
@ -209,18 +222,34 @@ export class BidsService {
}),
);
// Nếu phiên đấu giá chưa có thời gian bắt đầu và kết thúc
if (!bid.close_time && !bid.start_bid_time) {
// Thiết lập thời gian bắt đầu là 5 phút trước khi đóng
// bid.start_bid_time = new Date().toUTCString();
bid.start_bid_time = subtractMinutes(close_time, 5);
}
// Kiểm tra nếu thời gian đóng bid đã đạt tới (tức phiên đấu giá đã kết thúc)
if (bid.close_time && isTimeReached(bid.close_time)) {
const bidHistoriesItem = bid.histories[0]; // Lấy lịch sử bid gần nhất (mới nhất)
if (
data.current_price >= bid.max_price + bid.plus_price ||
(bid.close_time && isTimeReached(bid.close_time))
// Nếu giá cuối cùng không phải là giá của người dùng và giá hiện tại vượt quá mức người dùng đặt + bước giá
bidHistoriesItem.price !== data.current_price &&
data.current_price > bid.max_price + bid.plus_price
) {
bid.status = 'out-bid';
bid.status = 'out-bid'; // Người dùng đã bị outbid khi đấu giá kết thúc
} else {
bid.status = 'win-bid'; // Người dùng là người thắng nếu không bị outbid
}
} else {
// Nếu phiên đấu giá vẫn đang diễn ra và giá hiện tại vượt quá giới hạn đặt của người dùng
if (data.current_price > bid.max_price + bid.plus_price) {
bid.status = 'out-bid'; // Gán trạng thái là đã bị outbid
}
}
// Cập nhật thời gian kết thúc đấu giá nếu `close_time` mới lớn hơn `close_time` cũ
if (
close_time &&
new Date(close_time).getTime() > new Date(bid.close_time).getTime()
@ -228,19 +257,28 @@ export class BidsService {
bid.close_time = close_time;
}
// Nếu chưa có `model` nhưng dữ liệu mới có model, thì cập nhật model
if (model && !bid.model) {
bid.model = model;
}
// Lưu cập nhật vào database
const result = await this.bidsRepo.save({
...bid,
...data,
updated_at: new Date(),
updated_at: new Date(), // Cập nhật timestamp
});
// Phát sự kiện cập nhật toàn bộ danh sách đấu giá
this.emitAllBidEvent();
// send event message
if (result.status === 'out-bid') {
// Nếu trạng thái của bid là 'out-bid', gửi thông báo
if (['out-bid', 'win-bid'].includes(result.status)) {
this.notificationService.emitBidStatus(result);
}
// Trả về kết quả cập nhật dưới dạng response chuẩn
return AppResponse.toResponse(plainToClass(Bid, result));
}
@ -408,7 +446,7 @@ export class BidsService {
let filePath: string;
if (name === 'working') {
if (name === Event.WORKING) {
const files = readdirSync(folderPath).filter((file) =>
/\.(jpg|jpeg|png|webp)$/i.test(file),
);
@ -440,4 +478,26 @@ export class BidsService {
return res.sendFile(filePath);
}
async getImagesWorking(id: Bid['id'], type: string) {
const rootDir = process.cwd();
const folderPath = join(
rootDir,
`${Constant.MEDIA_PATH}/${Constant.WORK_IMAGES_FOLDER}/${type}/${id}`,
);
if (!existsSync(folderPath)) {
throw new NotFoundException(
AppResponse.toResponse(null, {
message: 'Folder not found',
status_code: HttpStatus.NOT_FOUND,
}),
);
}
// Lấy danh sách file trong folderPath
const files = readdirSync(folderPath);
return AppResponse.toResponse(files);
}
}

View File

@ -0,0 +1,131 @@
import { extractVerifyCodeLANGTONS, verifyCode } from '@/ultils';
import { ConfigService } from '@nestjs/config';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { WebSocketGateway } from '@nestjs/websockets';
import * as Imap from 'imap';
import { Server, Socket } from 'socket.io';
import { WebBidsService } from '../web-bids.service';
import { Like } from 'typeorm';
import { Event } from '../../utils/events';
import { plainToClass } from 'class-transformer';
import { WebBid } from '../../entities/wed-bid.entity';
@WebSocketGateway({
namespace: 'admin-bid-ws',
cors: {
origin: '*',
methods: ['GET', 'POST'],
credentials: true,
},
})
export class ImapService {
private imap: Imap;
server: Server;
constructor(
private readonly configService: ConfigService,
private readonly webBidsService: WebBidsService,
private eventEmitter: EventEmitter2,
) {
this.imap = new Imap({
user: this.configService.get<string>('MAIL_USER'),
password: this.configService.get<string>('MAIL_PASSWORD'),
host: this.configService.get<string>('MAIL_SERVER'),
port: 993,
tls: true,
tlsOptions: { rejectUnauthorized: false },
});
}
async connectIMAP() {
this.imap.once('ready', () => {
console.log('📥 IMAP Connected. Listening for new emails...');
this.openInbox();
});
this.imap.on('error', (err) => {
console.error('❌ IMAP Error:', err);
});
this.imap.on('end', () => {
console.log('📴 IMAP Connection Ended');
setTimeout(() => this.connectIMAP(), 5000);
});
this.imap.connect();
}
private openInbox() {
this.imap.openBox('INBOX', false, (err, box) => {
if (err) {
console.error('❌ Error opening inbox:', err);
return;
}
console.log('📬 INBOX opened, waiting for new emails...');
// Lắng nghe email mới
this.imap.on('mail', (numNewMsgs: number) => {
console.log(`📩 New email received! (${numNewMsgs} new messages)`);
this.fetchLatestEmail();
});
});
}
private fetchLatestEmail() {
this.imap.search(['UNSEEN'], (err, results) => {
if (err || !results.length) {
console.log('📭 No new unread emails.');
return;
}
const fetchOptions = { bodies: '', struct: true };
const f = this.imap.fetch(results.slice(-1), fetchOptions); // Lấy email mới nhất
f.on('message', (msg, seqno) => {
console.log(`📥 Fetching email #${seqno}`);
let emailContent = '';
let header = '';
msg.on('body', (stream) => {
stream.on('data', (chunk) => {
emailContent += chunk.toString();
});
stream.on('end', async () => {
const result = verifyCode(emailContent);
if (!result) {
throw new Error('fetchLatestEmail: Name or Code is empty');
}
const { code, name } = result;
const webBid = await this.webBidsService.webBidRepo.findOne({
where: { origin_url: Like(`%${name.toLocaleLowerCase()}%`) },
});
if (!webBid) {
throw new Error('Not found web bid');
}
// send message event
this.eventEmitter.emit(Event.verifyCode(webBid), {
code,
name,
web_bid: plainToClass(WebBid, webBid),
});
});
});
});
f.on('error', (err) => {
console.error('❌ Fetch error:', err);
});
f.on('end', () => {
console.log('✅ Done fetching emails.');
});
});
}
}

View File

@ -53,11 +53,30 @@ export class OutBidLogsService {
skipUpdateIfNoValuesChanged: true,
});
if (!result) throw new BadRequestException(false);
if (!result) throw new BadRequestException(AppResponse.toResponse(false));
return AppResponse.toResponse(true);
} catch (error) {
throw new BadRequestException(false);
throw new BadRequestException(AppResponse.toResponse(false));
}
}
async createSimple({ model, lot_id, ...data }: CreateOutBidLogDto) {
const prev = await this.outbidLogRepo.findOne({
where: { lot_id: lot_id, model: model },
});
if (!prev) {
const result = await this.outbidLogRepo.save({ ...data, lot_id, model });
return AppResponse.toResponse(!!result);
}
const result = await this.outbidLogRepo.update(
{ model: model, lot_id: lot_id },
{ ...data },
);
return AppResponse.toResponse(!!result);
}
}

View File

@ -21,6 +21,7 @@ import { extractDomain } from 'src/ultils';
import { Bid } from '../entities/bid.entity';
import { CreateWebBidDto } from '../dto/web-bid/create-web-bid';
import { UpdateWebBidDto } from '../dto/web-bid/update-web-bid';
import { Event } from '../utils/events';
@Injectable()
export class WebBidsService {
@ -73,13 +74,16 @@ export class WebBidsService {
async emitAllBidEvent() {
const data = await this.getDataClient();
this.eventEmitter.emit('bids.updated', data);
this.eventEmitter.emit(Event.BIDS_UPDATED, data);
}
async emitAccountUpdate(id: WebBid['id']) {
const data = await this.webBidRepo.findOne({ where: { id } });
const data = await this.webBidRepo.findOne({
where: { id, children: { status: 'biding' } },
relations: { children: true },
});
this.eventEmitter.emit('web.updated', data || null);
this.eventEmitter.emit(Event.WEB_UPDATED, data || null);
}
async createByUrl(url: string) {

View File

@ -0,0 +1,13 @@
import { WebBid } from '../entities/wed-bid.entity';
export class Event {
public static VERIFY_CODE = 'verify-code';
public static WORKING = 'working';
public static BIDS_UPDATED = 'bidsUpdated';
public static ADMIN_BIDS_UPDATED = 'adminBidsUpdated';
public static WEB_UPDATED = 'webUpdated';
public static verifyCode(data: WebBid) {
return `${this.VERIFY_CODE}.${data.origin_url}`;
}
}

View File

@ -0,0 +1,31 @@
import { BotTelegramApi } from '@/modules/bids/apis/bot-telegram.api';
import { Body, Controller, Get, Post } from '@nestjs/common';
import { SendMessageDto } from '../dto/send-message.dto';
import { NotificationService } from '../notification.service';
import { BidsService } from '@/modules/bids/services/bids.service';
@Controller('notifications')
export class ClientNotificationController {
constructor(
private botTelegramApi: BotTelegramApi,
private readonly notifyService: NotificationService,
private readonly bidsService: BidsService,
) {}
@Post('send-messages')
async sendMessage(@Body() data: SendMessageDto) {
return await this.botTelegramApi.sendMessage(data.text, data.options);
}
@Post('test')
async test() {
const bid = await this.bidsService.bidsRepo.findOne({
where: { lot_id: '26077023' },
});
return await this.notifyService.emitBidStatus({
...bid,
status: 'win-bid',
});
}
}

View File

@ -0,0 +1,12 @@
import { Controller, Get } from '@nestjs/common';
import { BotTelegramApi } from '../../bids/apis/bot-telegram.api';
@Controller('admin/notifications')
export class NotificationController {
constructor(private botTelegramApi: BotTelegramApi) {}
@Get('')
async test() {
return await this.botTelegramApi.getGroupInfo();
}
}

View File

@ -0,0 +1,9 @@
import { IsObject, IsString } from 'class-validator';
export class SendMessageDto {
@IsString()
text: string;
@IsObject()
options: Record<string, string>;
}

View File

@ -1,5 +1,5 @@
import { Test, TestingModule } from '@nestjs/testing';
import { NotificationController } from './notification.controller';
import { NotificationController } from './controllers/notification.controller';
import { NotificationService } from './notification.service';
describe('NotificationController', () => {

View File

@ -1,16 +0,0 @@
import { Controller, Get } from '@nestjs/common';
import { BotTelegramApi } from '../bids/apis/bot-telegram.api';
import { NotificationService } from './notification.service';
@Controller('admin/notifications')
export class NotificationController {
constructor(
private readonly notificationService: NotificationService,
private botTelegramApi: BotTelegramApi,
) {}
@Get('')
async test() {
return await this.botTelegramApi.getGroupInfo();
}
}

View File

@ -1,17 +1,20 @@
import { forwardRef, Module } from '@nestjs/common';
import { NotificationService } from './notification.service';
import { NotificationController } from './notification.controller';
import { BidsModule } from '../bids/bids.module';
import { Notification } from './entities/notification.entity';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BidsModule } from '../bids/bids.module';
import { ClientNotificationController } from './controllers/client-notification.controller';
import { NotificationController } from './controllers/notification.controller';
import { Notification } from './entities/notification.entity';
import { AdminNotificationListener } from './listeners/admin-notification.listener';
import { NotificationService } from './notification.service';
import { SendMessageHistoriesService } from '../bids/services/send-message-histories.service';
import { SendMessageHistory } from '../bids/entities/send-message-histories.entity';
@Module({
imports: [
forwardRef(() => BidsModule),
TypeOrmModule.forFeature([Notification]),
TypeOrmModule.forFeature([Notification, SendMessageHistory]),
],
controllers: [NotificationController],
controllers: [NotificationController, ClientNotificationController],
providers: [NotificationService, AdminNotificationListener],
exports: [NotificationService],
})

View File

@ -5,7 +5,7 @@ import { NAME_EVENTS } from './constants';
import { BotTelegramApi } from '../bids/apis/bot-telegram.api';
import { InjectRepository } from '@nestjs/typeorm';
import { Notification } from './entities/notification.entity';
import { Repository } from 'typeorm';
import { LEGAL_TCP_SOCKET_OPTIONS, Repository } from 'typeorm';
import { isTimeReached } from '@/ultils';
import {
FilterOperator,
@ -15,6 +15,8 @@ import {
} from 'nestjs-paginate';
import { Column } from 'nestjs-paginate/lib/helper';
import AppResponse from '@/response/app-response';
import { SendMessageHistoriesService } from '../bids/services/send-message-histories.service';
import { SendMessageHistory } from '../bids/entities/send-message-histories.entity';
@Injectable()
export class NotificationService {
@ -23,6 +25,8 @@ export class NotificationService {
private readonly botTelegramApi: BotTelegramApi,
@InjectRepository(Notification)
readonly notificationRepo: Repository<Notification>,
@InjectRepository(SendMessageHistory)
readonly sendMessageRepo: Repository<SendMessageHistory>,
) {}
async index(query: PaginateQuery) {
@ -50,6 +54,8 @@ export class NotificationService {
}
getBidStatusMessage(bid: Bid): string | null {
const itemName = `*${bid.name || 'the item'}*`;
switch (bid.status) {
case 'biding':
return !bid.name ? null : `✅ The item has been activated. ${bid.name}`;
@ -59,8 +65,6 @@ export class NotificationService {
return `⏳ The auction for *${bid.name || 'this item'}* has ended.`;
}
const itemName = `*${bid.name || 'the item'}*`;
if (
bid.max_price + bid.plus_price <= bid.current_price ||
bid.reserve_price > bid.max_price + bid.plus_price
@ -85,6 +89,14 @@ export class NotificationService {
const message = this.getBidStatusMessage(bid);
if (!message) return;
if (bid.status === 'win-bid') {
const prevSendMessage = await this.sendMessageRepo.findOne({
where: { bid: { id: bid.id }, type: 'win-bid' },
});
if (prevSendMessage) return;
}
const notification = await this.notificationRepo.save({
message,
raw_data: JSON.stringify({
@ -97,6 +109,12 @@ export class NotificationService {
send_to: JSON.stringify(sendToData),
});
await this.sendMessageRepo.save({
bid: { id: bid.id },
message: notification.message,
type: bid.status,
});
this.eventEmitter.emit(NAME_EVENTS.BID_STATUS, {
bid: {
...bid,

View File

@ -1,6 +1,18 @@
export function extractModelId(url: string): string | null {
switch (extractDomain(url)) {
case 'https://www.grays.com': {
const match = url.match(/\/lot\/([\d-]+)\//);
return match ? match[1] : null;
}
case 'https://www.langtons.com.au': {
const match = url.match(/auc-var-\d+/);
return match[0];
}
case 'https://www.lawsons.com.au': {
const match = url.split('_');
return match ? match[1] : null;
}
}
}
export function subtractMinutes(timeStr: string, minutes: number) {
@ -28,3 +40,60 @@ export function extractDomain(url: string): string | null {
export function escapeMarkdownV2(text: string) {
return text.replace(/[_*[\]()~`>#+\-=|{}.!\\]/g, '\\$&');
}
export function extractVerifyCodeLANGTONS(content: string) {
const match = content.match(/Your\s+(\w+)\s+verific.*?code\s+is:\s+(\d+)/i);
if (match) {
const name = match[1]; // LANGTONS
const code = match[2]; // 012152
return {
name,
code,
};
} else {
return null;
}
}
export function extractVerifyCodeLAWSONS(content: string) {
if (!content) return null;
// Loại bỏ các ký tự `=` không cần thiết do email encoding (quoted-printable)
content = content.replace(/=\r\n/g, '').replace(/=3D/g, '=');
// Regex để tìm mã xác minh (6 chữ số)
const codeMatch = content.match(/Verification code:\s*(\d{6})/);
const code = codeMatch ? codeMatch[1] : null;
// Regex để tìm tên (trong đoạn "Lawsons | Sydney, AU")
const nameMatch = content.match(/([\w\s]+)\s*\|\s*[\w\s,]+/);
const name = nameMatch ? nameMatch[1].trim() : null;
// Nếu không tìm thấy cả hai giá trị, trả về null
if (!code && !name) {
return null;
}
return { code, name };
}
export function verifyCode(content: string) {
// Kiểm tra mã xác minh từ LANGTONS
const langtonsResult = extractVerifyCodeLANGTONS(content);
if (langtonsResult) {
return langtonsResult;
}
// Nếu không có kết quả, tiếp tục kiểm tra mã xác minh từ LAWSONS
const lawsonsResult = extractVerifyCodeLAWSONS(content);
if (lawsonsResult) {
return lawsonsResult; // Trả về kết quả đúng thay vì langtonsResult
}
// Nếu không tìm thấy mã xác minh, trả về null
return null;
}

3
auto-bid-tool/.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"cSpell.words": ["Langtons", "Lawsons"]
}

View File

@ -0,0 +1,29 @@
{
"action": "Auction-LotsData",
"queryString": "auctionBpa=18&auctionCatalogPrice=16&auctionId=7402&auctionUnitQuantity=1&includeQuickViewLotData=true&lotIds=26033882&pid=auc-var-26033882&productName=VICKERY%20WINES%20Riesling%2C%20Eden%20Valley%202016%20Bottle",
"locale": "en_AU",
"csrf": {
"tokenName": "csrf_token",
"token": "I0GEkb97Y1mbVWixUo2f7sXj4EChyVDMO-CT3I6vV90VEfRJFLio5eBbartpUOaiJUZci22TbjoAnR-UVxaIJ2Umj51lWikd3oUxsTGM_ObrtgFiQXQqxJjY4D98BWf7_tI4uAu-kG6uttjdwArabsiJMx6qDGG0wvH2FLBQ8fpK9IjIBXo="
},
"success": true,
"lotData": {
"lotId": "26033882",
"currentMaxBid": 17,
"minimumBid": 18,
"numberOfBidders": 2,
"numberOfWatchers": 1,
"hasMetReserve": true,
"bidStatus": "Winning",
"timestamp": 49568786,
"myBid": "20"
},
"productBPA": "18.00",
"priceIncBPA": "$18.88",
"auctionPrice": "$16.00",
"totalAuctionPrice": "$16.00",
"lotId": "26033882",
"pid": "auc-var-26033882",
"unitQuantity": "1",
"auctionBpa": "18"
}

View File

@ -7,6 +7,8 @@ import browser from './system/browser.js';
import configs from './system/config.js';
import { delay, isTimeReached, safeClosePage } from './system/utils.js';
global.IS_CLEANING = true;
let MANAGER_BIDS = [];
const activeTasks = new Set();
@ -129,6 +131,11 @@ const tracking = async () => {
};
const clearLazyTab = async () => {
if (!global.IS_CLEANING) {
console.log('🚀 Cleaning flag is OFF. Proceeding with operation.');
return;
}
if (!browser) {
console.warn('⚠️ Browser is not available or disconnected.');
return;
@ -203,6 +210,9 @@ const workTracking = async () => {
},
});
// set socket on global app
global.socket = socket;
// listen connect
socket.on('connect', () => {
console.log('✅ Connected to WebSocket server');
@ -229,11 +239,16 @@ const workTracking = async () => {
if (isDeleted) {
console.log('✅ Profile deleted successfully!');
const tabs = MANAGER_BIDS.filter((item) => item.url === data.url || item?.web_bid.url === data.url);
const tab = MANAGER_BIDS.find((item) => item.url === data.url);
if (tabs.length <= 0) return;
if (!tab) return;
await Promise.all(tabs.map((tab) => safeClosePage(tab)));
global.IS_CLEANING = false;
await Promise.all(tab.children.map((tab) => safeClosePage(tab)));
await safeClosePage(tab);
global.IS_CLEANING = true;
} else {
console.log('⚠️ No profile found to delete.');
}

View File

@ -39,44 +39,43 @@ export class GrayApiBid extends ApiBid {
// }
// });
console.log('🔄 Starting polling process...');
console.log(`🔄 [${this.id}] Starting polling process...`);
await page.evaluateHandle(
(apiUrl, interval) => {
(apiUrl, interval, bidId) => {
if (window._autoBidPollingStarted) {
console.log('✅ Polling is already running. Skipping initialization.');
console.log(`✅ [${bidId}] Polling is already running. Skipping initialization.`);
return;
}
console.log('🚀 Initializing polling...');
console.log(`🚀 [${bidId}] Initializing polling...`);
window._autoBidPollingStarted = true;
function sendRequest() {
console.log('📡 Sending request to track out-bid lots...');
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(`Response received: ${response.status}`))
.catch((err) => console.error('⚠️ Request error:', err));
.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,
);
console.log('✅ Polling successfully started!');
} catch (error) {
if (error.message.includes('Execution context was destroyed')) {
console.log('⚠️ Page reload detected, restarting polling...');
console.log(`⚠️ [${this.id}] Page reload detected, restarting polling...`);
await page.waitForNavigation({ waitUntil: 'networkidle2' }).catch(() => {});
return await this.polling(page);
}
console.error('🚨 Unexpected polling error:', error);
console.error(`🚨 [${this.id}] Unexpected polling error:`, error);
throw error;
}
}
@ -110,7 +109,7 @@ export class GrayApiBid extends ApiBid {
for (const product_tab of handleChildren) {
if (!isTimeReached(product_tab.start_bid_time)) {
console.log("❌ It's not time yet ID: " + product_tab.id + ' continue waiting...');
console.log(`❌ [${this.id}] It's not time yet ID: ${product_tab.id} continue waiting...`);
return;
}
@ -129,17 +128,20 @@ export class GrayApiBid extends ApiBid {
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('✅ Already logged in, skipping login.');
console.log(`✅ [${this.id}] Already logged in, skipping login.`);
global.IS_CLEANING = true;
this.retry_login = 0; // Reset retry count
return;
}
console.log('🔑 Starting login process...');
console.log(`🔑 [${this.id}] Starting login process...`);
try {
await page.type('input[name="username"]', this.username, { delay: 100 });
@ -152,18 +154,18 @@ export class GrayApiBid extends ApiBid {
]);
if (!(await page.$('input[name="username"]'))) {
console.log('✅ Login successful!');
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(`⚠️ Login error: ${error.message}. Retrying attempt ${this.retry_login + 1}`);
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('🚨 Maximum login attempts reached. Stopping login process.');
console.log(`🚨 [${this.id}] Maximum login attempts reached. Stopping login process.`);
safeClosePage(this);
this.retry_login = 0; // Reset retry count
return;
@ -177,6 +179,8 @@ export class GrayApiBid extends ApiBid {
}
return await this.action(); // Retry login
} finally {
global.IS_CLEANING = true;
}
}
@ -185,43 +189,38 @@ export class GrayApiBid extends ApiBid {
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');
// await takeSnapshot(this.page_context, this, 'working', CONSTANTS.TYPE_IMAGE.WORK);
console.log(`🛠️ [${this.id}] UserAgent set.`);
page.on('response', async (response) => {
if (response.request().url().includes('api/Notifications/GetOutBidLots')) {
console.log('🚀 API POST:', response.url());
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('❌ Error get response', error?.message);
console.error(`❌ [${this.id}] Error processing response:`, error?.message);
}
}
});
page.on('load', async () => {
console.log('🔄 The page has reloaded, restarting polling...');
// await takeSnapshot(this.page_context, this, 'working', CONSTANTS.TYPE_IMAGE.WORK);
console.log(`🔄 [${this.id}] Page has reloaded, restarting polling...`);
await this.polling(page);
await this.handleLogin();
});
await this.polling(page); // Call when fist load
await this.polling(page); // Call when first load
await this.handleLogin();
} catch (error) {
console.log(error.message);
console.log(`❌ [${this.id}] Action error: ${error.message}`);
}
};
}

View File

@ -10,12 +10,12 @@ export class GraysProductBid extends ProductBid {
async validate({ page, price_value }) {
if (!this.start_bid_time || !isTimeReached(this.start_bid_time)) {
console.log("It's not time yet ❌");
console.log(`❌ [${this.id}] It's not time yet`);
return { result: false, bid_price: 0 };
}
if (!isNumber(price_value)) {
console.log("Can't get PRICE_VALUE ❌");
console.log(`❌ [${this.id}] Can't get PRICE_VALUE`);
await takeSnapshot(page, this, 'price-value-null');
return { result: false, bid_price: 0 };
@ -24,7 +24,7 @@ export class GraysProductBid extends ProductBid {
const bid_price = this.plus_price + Number(price_value);
if (bid_price > this.max_price) {
console.log('PRICE BID is more than MAX_VALUE => STOP BID THIS PRODUCT ❌');
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);
@ -92,7 +92,7 @@ export class GraysProductBid extends ProductBid {
await delay(500);
if (!close_time || new Date(close_time).getTime() <= new Date().getTime()) {
console.log(`Product is close ${close_time}`);
console.log(`❌ [${this.id}] Product is close ${close_time}`);
return { result: true, close_time };
}
@ -123,7 +123,7 @@ export class GraysProductBid extends ProductBid {
await takeSnapshot(page, this, 'bid-success', CONSTANTS.TYPE_IMAGE.SUCCESS);
return true;
} catch (error) {
console.log('❌ Timeout to loading');
console.log(`❌ [${this.id}] Timeout to loading`);
await takeSnapshot(page, this, 'timeout to loading');
return false;
}
@ -169,7 +169,7 @@ export class GraysProductBid extends ProductBid {
.$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(`📌 Product Info: Lot ID: ${lot_id}, Name: ${name}, Current Price: ${current_price}, Reserve price: ${price_value}`);
console.log(`📌 [${this.id}] Product Info: Lot ID: ${lot_id}, Name: ${name}, Current Price: ${current_price}, Reserve price: ${price_value}`);
const data = removeFalsyValues(
{
@ -196,54 +196,50 @@ export class GraysProductBid extends ProductBid {
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('❌ The product is closed, cannot place a bid.');
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('❌ Validation failed. Unable to proceed with bidding.');
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(`🔄 You have already bid on this item! (Bid Price: ${bidHistoriesItem.price})`);
console.log(`🔄 [${this.id}] You have already bid on this item! (Bid Price: ${bidHistoriesItem.price})`);
return;
}
if (price_value != bid_price) {
console.log(`✍️ Updating bid price from ${price_value}${bid_price}`);
console.log(`✍️ [${this.id}] Updating bid price from ${price_value}${bid_price}`);
await this.handleWritePrice(page, bid_price);
}
console.log('🚀 Placing the bid...');
console.log(`🚀 [${this.id}] Placing the bid...`);
const resultPlaceBid = await this.placeBid(page);
if (!resultPlaceBid) {
console.log('❌ Error occurred while placing the bid.');
console.log(`❌ [${this.id}] Error occurred while placing the bid.`);
await takeSnapshot(page, this, 'place-bid-action');
return;
}
console.log(`✅ Bid placed successfully! 🏆 Bid Price: ${bid_price}, Closing Time: ${close_time}`);
console.log(`✅ [${this.id}] Bid placed successfully! 🏆 Bid Price: ${bid_price}, Closing Time: ${close_time}`);
await this.handleReturnProductPage(page);
} catch (error) {
console.error(`🚨 Error navigating the page: ${error.message}`);
console.error(`🚨 [${this.id}] Error navigating the page: ${error.message}`);
}
};
}

View File

@ -0,0 +1,225 @@
import fs from 'fs';
import configs from '../../system/config.js';
import { getPathProfile, safeClosePage } from '../../system/utils.js';
import { ApiBid } from '../api-bid.js';
import _ from 'lodash';
import { updateStatusByPrice } from '../../system/apis/bid.js';
export class LangtonsApiBid extends ApiBid {
reloadInterval = null;
constructor({ ...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.`));
}, 60 * 1000); // 60 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 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;
}
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: 8000, waitUntil: 'domcontentloaded' });
// 📂 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;
}
// Lọc danh sách `this.children` chỉ giữ lại những item có trong danh sách thắng
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
const responses = await Promise.allSettled(result.map((i) => updateStatusByPrice(i.id, i.current_price)));
// Log kết quả của mỗi request
responses.forEach((response, index) => {
if (response.status === 'fulfilled') {
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();
}
}
});
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);
}
};
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.`);
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);
};
}

View File

@ -0,0 +1,394 @@
import _ from 'lodash';
import { outBid, pushPrice, updateBid } from '../../system/apis/bid.js';
import { sendMessage } from '../../system/apis/notification.js';
import { createOutBidLog } from '../../system/apis/out-bid-log.js';
import configs from '../../system/config.js';
import CONSTANTS from '../../system/constants.js';
import { convertAETtoUTC, removeFalsyValues, takeSnapshot } from '../../system/utils.js';
import { ProductBid } from '../product-bid.js';
export class LangtonsProductBid extends ProductBid {
constructor({ ...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() + 2 * 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 = 10000) {
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(() => {
console.log(`⏳ [${this.id}] Timeout: No response received within 10s`);
this.page_context.off('response', onResponse); // Gỡ bỏ listener khi timeout
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');
return el;
});
} catch (error) {
return null;
}
}
async handlePlaceBid() {
if (!this.page_context) {
console.log(`⚠️ [${this.id}] No page context found, aborting bid process.`);
return;
}
const page = this.page_context;
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...`);
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
}
// Đợ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: 100 });
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: 10000 });
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}`);
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 != 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

@ -0,0 +1,260 @@
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 LawsonsApiBid extends ApiBid {
reloadInterval = null;
constructor({ ...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.`));
}, 60 * 1000); // 60 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.$('#emailLogin')) && fs.existsSync(filePath);
}
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.`));
}, 60 * 1000); // 60 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 enterOTP(otp) {
try {
// Selector cho tất cả các input OTP
const inputSelector = '.MuiDialog-container .container input';
// Chờ cho các input OTP xuất hiện
await this.page_context.waitForSelector(inputSelector, { timeout: 8000 });
// Lấy tất cả các input OTP
const inputs = await this.page_context.$$(inputSelector);
// Kiểm tra nếu có đúng 6 trường input
if (inputs.length === 6 && otp.length === 6) {
// Nhập mỗi ký tự của OTP vào các input tương ứng
for (let i = 0; i < 6; i++) {
await inputs[i].type(otp[i], { delay: 100 });
}
console.log(`✅ OTP entered successfully: ${otp}`);
} else {
console.error('❌ Invalid OTP or input fields count');
}
} catch (error) {
console.error('❌ Error entering OTP:', error);
}
}
async waitToTwoVerify() {
try {
if (!this.page_context) return false;
// Selector của các phần tử trên trang
const button = '.form-input-wrapper.form-group > .btn.btn-primary'; // Nút để tiếp tục quá trình xác minh
const remember = '.PrivateSwitchBase-input'; // Checkbox "Remember me"
const continueButton =
'.MuiButtonBase-root.MuiButton-root.MuiButton-contained.MuiButton-containedPrimary.MuiButton-sizeMedium.MuiButton-containedSizeMedium.MuiButton-colorPrimary.MuiButton-root'; // Nút "Continue"
// Chờ cho nút xác minh xuất hiện
console.log(`🔎 [${this.id}] Waiting for the button with selector: ${button}`);
await this.page_context.waitForSelector(button, { timeout: 8000 });
console.log(`✅ [${this.id}] Button found, clicking the first button.`);
// Lấy phần tử của nút và log nội dung của nó
const firstButton = await this.page_context.$(button); // Lấy phần tử đầu tiên
const buttonContent = await firstButton.evaluate((el) => el.textContent); // Lấy nội dung của nút
console.log(`🔎 [${this.id}] Button content: ${buttonContent}`);
// Chờ 2s cho button sẵn sàn
await delay(2000);
// Click vào nút xác minh
await firstButton.click();
console.log(`✅ [${this.id}] Button clicked.`);
// Nhận mã OTP để nhập vào form
const { name, code } = await this.waitVerifyData();
console.log(`🔎 [${this.id}] Waiting for OTP input, received code: ${code}`);
// Nhập mã OTP vào form
await this.enterOTP(code);
console.log(`✅ [${this.id}] OTP entered successfully.`);
// Chờ cho checkbox "Remember me" xuất hiện
await this.page_context.waitForSelector(remember, { timeout: 8000 });
console.log(`🔎 [${this.id}] Waiting for remember me checkbox with selector: ${remember}`);
// Click vào checkbox "Remember me"
await this.page_context.click(remember, { delay: 92 });
console.log(`✅ [${this.id}] Remember me checkbox clicked.`);
// Chờ cho nút "Continue" xuất hiện
await this.page_context.waitForSelector(continueButton, { timeout: 8000 });
console.log(`🔎 [${this.id}] Waiting for continue button with selector: ${continueButton}`);
// Click vào nút "Continue"
await this.page_context.click(continueButton, { delay: 100 });
console.log(`✅ [${this.id}] Continue button clicked.`);
// Chờ cho trang tải hoàn tất sau khi click "Continue"
await this.page_context.waitForNavigation({ waitUntil: 'domcontentloaded' });
console.log(`✅ [${this.id}] Navigation completed.`);
return true;
} catch (error) {
console.error(`❌ [${this.id}] Error:`, error);
return false;
}
}
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.$('#emailLogin')) && 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('#emailLogin', this.username, { delay: 100 });
// ⌨ Enter password
console.log(`✍ [${this.id}] Entering password...`);
await page.type('#passwordLogin', this.password, { delay: 150 });
// 🚀 Click the login button
console.log(`🔘 [${this.id}] Clicking the "Login" button`);
await page.click('#signInBtn', { delay: 92 });
const result = await this.waitToTwoVerify();
// ⏳ Wait for navigation after login
if (!result) {
console.log(`⏳ [${this.id}] Waiting for navigation after login...`);
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;
}
}
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.LAWSONS.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);
}
};
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.`);
// 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);
};
}

View File

@ -0,0 +1,114 @@
import { updateBid } from '../../system/apis/bid.js';
import configs from '../../system/config.js';
import { extractPriceNumber, removeFalsyValues } from '../../system/utils.js';
import { ProductBid } from '../product-bid.js';
export class LawsonsProductBid 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;
}
}
async getReversePrice() {
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('.select-dropdown-value.text-truncate', { timeout: 2000 });
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);
// Nếu có lỗi xảy ra trong quá trình lấy thời gian, trả về null
return null;
}
}
update = async () => {
try {
if (!this.page_context) return;
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,
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);
};
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 };
}
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();
}
} catch (error) {
console.error(`🚨 [${this.id}] Error navigating the page: ${error}`);
}
};
}

View File

@ -135,16 +135,6 @@ export class ProductBid extends Bid {
return true;
}
// async gotoLink() {
// const page = this.page_context;
// console.log('🔄 Starting the bidding process...');
// await page.goto(this.url, { waitUntil: 'networkidle2' });
// console.log(`✅ Navigated to: ${this.url}`);
// await page.bringToFront();
// console.log('👀 Brought the tab to the foreground.');
// }
async gotoLink() {
const page = this.page_context;
@ -160,6 +150,7 @@ export class ProductBid extends Bid {
console.log(`✅ Navigated to: ${this.url}`);
await page.bringToFront();
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,13 +1,16 @@
import * as fs from 'fs';
import path from 'path';
import { GrayApiBid } from '../models/grays.com/grays-api-bid.js';
import { GraysProductBid } from '../models/grays.com/grays-product-bid.js';
import { LangtonsApiBid } from '../models/langtons.com.au/langtons-api-bid.js';
import { LangtonsProductBid } from '../models/langtons.com.au/langtons-product-bid.js';
import configs from '../system/config.js';
import CONSTANTS from '../system/constants.js';
import { sanitizeFileName } from '../system/utils.js';
import * as fs from 'fs';
import _ from 'lodash';
import { LawsonsApiBid } from '../models/lawsons.com.au/lawsons-api-bid.js';
import { LawsonsProductBid } from '../models/lawsons.com.au/lawsons-product-bid.js';
const ONE_MINUTE = 60 * 1000;
const TIME = 30 * 1000;
export const handleCloseRemoveProduct = (data) => {
if (!Array.isArray(data)) return;
@ -24,6 +27,12 @@ export const createBidProduct = (web, data) => {
case configs.WEB_URLS.GRAYS: {
return new GraysProductBid({ ...data });
}
case configs.WEB_URLS.LANGTONS: {
return new LangtonsProductBid({ ...data });
}
case configs.WEB_URLS.LAWSONS: {
return new LawsonsProductBid({ ...data });
}
}
};
@ -32,6 +41,12 @@ export const createApiBid = (web) => {
case configs.WEB_URLS.GRAYS: {
return new GrayApiBid({ ...web });
}
case configs.WEB_URLS.LANGTONS: {
return new LangtonsApiBid({ ...web });
}
case configs.WEB_URLS.LAWSONS: {
return new LawsonsApiBid({ ...web });
}
}
};
@ -49,5 +64,6 @@ export const deleteProfile = (data) => {
export const shouldUpdateProductTab = (productTab) => {
const updatedAt = new Date(productTab.updated_at).getTime();
const now = Date.now();
return now - updatedAt >= ONE_MINUTE;
return now - updatedAt >= TIME;
};

View File

@ -14,7 +14,7 @@ export const getBids = async () => {
return data.data;
} catch (error) {
console.log('❌ ERROR IN SERVER: ', error);
console.log('❌ ERROR IN SERVER (GET BIDS): ', error);
return [];
}
};
@ -34,7 +34,7 @@ export const updateBid = async (id, values) => {
return data.data;
} catch (error) {
console.log('❌ ERROR IN SERVER: (UPDATE BID) ', error.response);
console.log('❌ ERROR IN SERVER: (UPDATE BID) ', error);
return null;
}
};
@ -73,7 +73,7 @@ export const pushPrice = async (values) => {
return { status: true, data: data.data };
} catch (error) {
console.log('❌ ERROR IN SERVER: ', error);
console.log('❌ ERROR IN SERVER (PUSH PRICE): ', error.response.data);
return { status: false, data: [] };
}
};
@ -95,7 +95,10 @@ export const updateStatusByPrice = async (id, current_price) => {
return { status: true, data: data.data };
} catch (error) {
console.log('❌ ERROR IN SERVER:(UPDATE STATUS BY PRICE) ', error.message);
console.log('❌ ERROR IN SERVER:(UPDATE STATUS BY PRICE) ', {
// response: error.response,
message: error.message,
});
return { status: false, data: [] };
}
};

View File

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

View File

@ -4,12 +4,29 @@ const configs = {
SOCKET_URL: process.env.SOCKET_URL,
WEB_URLS: {
GRAYS: `https://www.grays.com`,
LANGTONS: `https://www.langtons.com.au`,
LAWSONS: `https://www.lawsons.com.au`,
},
WEB_CONFIGS: {
GRAYS: {
AUTO_CALL_API_TO_TRACKING: 3000,
API_CALL_TO_TRACKING: 'https://www.grays.com/api/Notifications/GetOutBidLots',
},
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}`;
},
},
},
};

View File

@ -9,7 +9,6 @@ export const takeSnapshot = async (page, item, imageName, type = CONSTANTS.TYPE_
if (!page || page.isClosed()) return;
try {
global.IS_CLEANING = false;
const baseDir = path.join(CONSTANTS.ERROR_IMAGES_PATH, item.type, String(item.id)); // Thư mục theo lot_id
const typeDir = path.join(baseDir, type); // Thư mục con theo type
@ -101,3 +100,81 @@ export function removeFalsyValues(obj, excludeKeys = []) {
return acc;
}, {});
}
export const enableAutoBidMessage = (data) => {
return `
<b> Activate Auto Bid</b><br>
📌 Product: <b>${data.name}</b><br>
🔗 Link: <a href="${data.url}">Click here</a><br>
💰 Max Price: <b>$${data.max_price}</b><br>
🌐 Platform: <a href="${data.web_bid.origin_url}">Langtons</a>
`;
};
export function convertAETtoUTC(dateString) {
// Bảng ánh xạ tên tháng sang số (0-11, theo chuẩn JavaScript)
const monthMap = {
Jan: 0,
Feb: 1,
Mar: 2,
Apr: 3,
May: 4,
Jun: 5,
Jul: 6,
Aug: 7,
Sep: 8,
Oct: 9,
Nov: 10,
Dec: 11,
};
// Tách chuỗi đầu vào
const parts = dateString.match(/(\w+)\s(\d+)\s(\w+)\s(\d+),\s(\d+)\s(PM|AM)\sAET/);
if (!parts) {
throw new Error("Error format: 'Sun 6 Apr 2025, 9 PM AET'");
}
const [, , day, month, year, hour, period] = parts;
// Chuyển đổi giờ sang định dạng 24h
let hours = parseInt(hour, 10);
if (period === 'PM' && hours !== 12) hours += 12;
if (period === 'AM' && hours === 12) hours = 0;
// Tạo đối tượng Date ban đầu (chưa điều chỉnh múi giờ)
const date = new Date(Date.UTC(parseInt(year, 10), monthMap[month], parseInt(day, 10), hours, 0, 0));
// Hàm kiểm tra DST cho AET
function isDST(date) {
const year = date.getUTCFullYear();
const month = date.getUTCMonth();
const day = date.getUTCDate();
// DST bắt đầu: Chủ nhật đầu tiên của tháng 10 (2:00 AM AEST -> 3:00 AM AEDT)
const dstStart = new Date(Date.UTC(year, 9, 1, 0, 0, 0)); // 1/10
dstStart.setUTCDate(1 + ((7 - dstStart.getUTCDay()) % 7)); // Chủ nhật đầu tiên
const dstStartTime = dstStart.getTime() + 2 * 60 * 60 * 1000; // 2:00 AM UTC+10
// DST kết thúc: Chủ nhật đầu tiên của tháng 4 (3:00 AM AEDT -> 2:00 AM AEST)
const dstEnd = new Date(Date.UTC(year, 3, 1, 0, 0, 0)); // 1/4
dstEnd.setUTCDate(1 + ((7 - dstEnd.getUTCDay()) % 7)); // Chủ nhật đầu tiên
const dstEndTime = dstEnd.getTime() + 3 * 60 * 60 * 1000; // 3:00 AM UTC+11
const currentTime = date.getTime() + 10 * 60 * 60 * 1000; // Thời gian AET (giả định ban đầu UTC+10)
return currentTime >= dstStartTime && currentTime < dstEndTime;
}
// Xác định offset dựa trên DST
const offset = isDST(date) ? 11 : 10; // UTC+11 nếu DST, UTC+10 nếu không
// Điều chỉnh thời gian về UTC
const utcDate = new Date(date.getTime() - offset * 60 * 60 * 1000);
// Trả về chuỗi UTC
return utcDate.toUTCString();
}
export function extractPriceNumber(priceString) {
const cleaned = priceString.replace(/[^\d.]/g, '');
return parseFloat(cleaned);
}