scrape
This commit is contained in:
parent
168d458009
commit
4f9edf80d0
|
|
@ -0,0 +1,33 @@
|
|||
import { handleError } from ".";
|
||||
import axios from "../lib/axios";
|
||||
import { IConfig } from "../system/type";
|
||||
import { removeFalsyValues } from "../utils";
|
||||
|
||||
export const CONFIG_KEYS = {
|
||||
MAIL_SCRAP_REPORT: "MAIL_SCRAP_REPORT",
|
||||
};
|
||||
|
||||
export const getConfig = async (key: keyof typeof CONFIG_KEYS) => {
|
||||
return await axios({
|
||||
url: "configs/" + key,
|
||||
withCredentials: true,
|
||||
method: "GET",
|
||||
});
|
||||
};
|
||||
|
||||
export const upsertConfig = async (data: IConfig) => {
|
||||
const { key_name, value, type } = removeFalsyValues(data, ["value"]);
|
||||
|
||||
try {
|
||||
const { data } = await axios({
|
||||
url: "configs/upsert",
|
||||
withCredentials: true,
|
||||
method: "POST",
|
||||
data: { key_name, value: value || null, type },
|
||||
});
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
handleError(error);
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,161 @@
|
|||
import {
|
||||
ActionIcon,
|
||||
Box,
|
||||
Card,
|
||||
Group,
|
||||
LoadingOverlay,
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { useForm, zodResolver } from "@mantine/form";
|
||||
import { IconAt, IconMinus, IconPlus } from "@tabler/icons-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { z } from "zod";
|
||||
import { getConfig, upsertConfig } from "../../apis/config";
|
||||
import { IConfig } from "../../system/type";
|
||||
import { useConfirmStore } from "../../lib/zustand/use-confirm";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
|
||||
const schema = z.object({
|
||||
email: z
|
||||
.string({ message: "Email is required" })
|
||||
.email({ message: "Invalid email address" }),
|
||||
});
|
||||
|
||||
const MailInput = ({
|
||||
initValue,
|
||||
onDelete,
|
||||
onAdd,
|
||||
}: {
|
||||
initValue?: string;
|
||||
onDelete?: (data: string) => void;
|
||||
onAdd?: (data: string) => Promise<void>;
|
||||
}) => {
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
email: initValue || "",
|
||||
},
|
||||
validate: zodResolver(schema),
|
||||
});
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={form.onSubmit(
|
||||
onAdd
|
||||
? async (values) => {
|
||||
await onAdd(values.email);
|
||||
form.reset();
|
||||
}
|
||||
: () => {}
|
||||
)}
|
||||
className="flex items-start gap-2 w-full"
|
||||
>
|
||||
<TextInput
|
||||
{...form.getInputProps("email")}
|
||||
leftSection={<IconAt size={14} />}
|
||||
placeholder="Enter email"
|
||||
className="flex-1"
|
||||
size="xs"
|
||||
/>
|
||||
<ActionIcon
|
||||
onClick={initValue && onDelete ? () => onDelete(initValue) : undefined}
|
||||
type={!initValue ? "submit" : "button"}
|
||||
color={initValue ? "red" : "blue"}
|
||||
variant="light"
|
||||
>
|
||||
{initValue ? <IconMinus size={14} /> : <IconPlus size={14} />}
|
||||
</ActionIcon>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default function MailsConfig() {
|
||||
const [config, setConfig] = useState<null | IConfig>(null);
|
||||
const { setConfirm } = useConfirmStore();
|
||||
const [opened, { open, close }] = useDisclosure(false);
|
||||
useEffect(() => {
|
||||
fetchConfig();
|
||||
}, []);
|
||||
|
||||
const mails = useMemo(() => {
|
||||
if (!config) return [];
|
||||
|
||||
return config?.value?.split(", ").length > 0
|
||||
? config?.value.split(",")
|
||||
: [];
|
||||
}, [config]);
|
||||
|
||||
const fetchConfig = async () => {
|
||||
const response = await getConfig("MAIL_SCRAP_REPORT");
|
||||
|
||||
if (!response || ![200, 201].includes(response.data?.status_code)) return;
|
||||
|
||||
setConfig(response.data.data);
|
||||
};
|
||||
|
||||
const handleDelete = (mail: string) => {
|
||||
setConfirm({
|
||||
message: "Are you want to delete: " + mail,
|
||||
title: "Delete",
|
||||
handleOk: async () => {
|
||||
open();
|
||||
const newMails = mails.filter((item) => item !== mail);
|
||||
|
||||
if (!config) return;
|
||||
|
||||
const response = await upsertConfig({
|
||||
...(config as IConfig),
|
||||
value: newMails.join(", "),
|
||||
});
|
||||
|
||||
if (response) {
|
||||
fetchConfig();
|
||||
}
|
||||
close();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleAdd = async (mail: string) => {
|
||||
const newMails = [...mails, mail];
|
||||
|
||||
open();
|
||||
const response = await upsertConfig({
|
||||
...(config as IConfig),
|
||||
value: newMails.join(", "),
|
||||
});
|
||||
|
||||
if (response) {
|
||||
fetchConfig();
|
||||
}
|
||||
close();
|
||||
};
|
||||
|
||||
return (
|
||||
<Card withBorder shadow="sm" radius="md" w={400}>
|
||||
<Card.Section withBorder inheritPadding py="xs">
|
||||
<Group justify="space-between">
|
||||
<Text fw={500}>Mails</Text>
|
||||
</Group>
|
||||
</Card.Section>
|
||||
|
||||
<Card.Section p="md">
|
||||
<Box className="flex flex-col gap-2">
|
||||
{mails.length > 0 &&
|
||||
mails.map((mail) => {
|
||||
return (
|
||||
<MailInput
|
||||
onDelete={handleDelete}
|
||||
key={mail}
|
||||
initValue={mail}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<MailInput onAdd={handleAdd} />
|
||||
</Box>
|
||||
</Card.Section>
|
||||
|
||||
<LoadingOverlay visible={opened} />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,122 +1,180 @@
|
|||
import { Avatar, Button, LoadingOverlay, Menu, Modal, PasswordInput } from '@mantine/core';
|
||||
import { useForm, zodResolver } from '@mantine/form';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { IconKey, IconLogout, IconSettings, IconUser } from '@tabler/icons-react';
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { z } from 'zod';
|
||||
import { changePassword, logout } from '../apis/auth';
|
||||
import { useConfirmStore } from '../lib/zustand/use-confirm';
|
||||
import Links from '../system/links';
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
LoadingOverlay,
|
||||
Menu,
|
||||
Modal,
|
||||
PasswordInput,
|
||||
} from "@mantine/core";
|
||||
import { useForm, zodResolver } from "@mantine/form";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import {
|
||||
IconCode,
|
||||
IconKey,
|
||||
IconLogout,
|
||||
IconSettings,
|
||||
IconUser,
|
||||
} from "@tabler/icons-react";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { Link } from "react-router-dom";
|
||||
import { z } from "zod";
|
||||
import { changePassword, logout } from "../apis/auth";
|
||||
import { useConfirmStore } from "../lib/zustand/use-confirm";
|
||||
import Links from "../system/links";
|
||||
|
||||
const schema = z
|
||||
.object({
|
||||
currentPassword: z.string().min(6, 'Current password must be at least 6 characters'),
|
||||
newPassword: z.string().min(6, 'New password must be at least 6 characters'),
|
||||
confirmPassword: z.string(),
|
||||
})
|
||||
.refine((data) => data.newPassword === data.confirmPassword, {
|
||||
path: ['confirmPassword'],
|
||||
message: 'Passwords do not match',
|
||||
});
|
||||
.object({
|
||||
currentPassword: z
|
||||
.string()
|
||||
.min(6, "Current password must be at least 6 characters"),
|
||||
newPassword: z
|
||||
.string()
|
||||
.min(6, "New password must be at least 6 characters"),
|
||||
confirmPassword: z.string(),
|
||||
})
|
||||
.refine((data) => data.newPassword === data.confirmPassword, {
|
||||
path: ["confirmPassword"],
|
||||
message: "Passwords do not match",
|
||||
});
|
||||
|
||||
export default function UserMenu() {
|
||||
const [opened, { open, close }] = useDisclosure(false);
|
||||
const [opened, { open, close }] = useDisclosure(false);
|
||||
|
||||
const { setConfirm } = useConfirmStore();
|
||||
const { setConfirm } = useConfirmStore();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const navigate = useNavigate();
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: '',
|
||||
},
|
||||
validate: zodResolver(schema),
|
||||
const navigate = useNavigate();
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
currentPassword: "",
|
||||
newPassword: "",
|
||||
confirmPassword: "",
|
||||
},
|
||||
validate: zodResolver(schema),
|
||||
});
|
||||
|
||||
const handleSubmit = async (values: typeof form.values) => {
|
||||
await handleChangePassword(values);
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
setConfirm({
|
||||
title: "Are you wan't to logout?",
|
||||
message: "This account will logout !",
|
||||
okButton: { value: "Logout" },
|
||||
handleOk: async () => {
|
||||
const data = await logout();
|
||||
|
||||
if (data && data.data) {
|
||||
navigate(Links.LOGIN);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = async (values: typeof form.values) => {
|
||||
await handleChangePassword(values);
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
setConfirm({
|
||||
title: "Are you wan't to logout?",
|
||||
message: 'This account will logout !',
|
||||
okButton: { value: 'Logout' },
|
||||
handleOk: async () => {
|
||||
const data = await logout();
|
||||
|
||||
if (data && data.data) {
|
||||
navigate(Links.LOGIN);
|
||||
}
|
||||
},
|
||||
const handleChangePassword = async (values: typeof form.values) => {
|
||||
setConfirm({
|
||||
title: "Are you wan't to update password",
|
||||
message: "This account will change password !",
|
||||
okButton: { value: "Sure" },
|
||||
handleOk: async () => {
|
||||
setLoading(true);
|
||||
const data = await changePassword({
|
||||
newPassword: values.newPassword,
|
||||
password: values.currentPassword,
|
||||
});
|
||||
};
|
||||
|
||||
const handleChangePassword = async (values: typeof form.values) => {
|
||||
setConfirm({
|
||||
title: "Are you wan't to update password",
|
||||
message: 'This account will change password !',
|
||||
okButton: { value: 'Sure' },
|
||||
handleOk: async () => {
|
||||
setLoading(true);
|
||||
const data = await changePassword({
|
||||
newPassword: values.newPassword,
|
||||
password: values.currentPassword,
|
||||
});
|
||||
setLoading(false);
|
||||
|
||||
setLoading(false);
|
||||
if (data && data.data) {
|
||||
navigate(Links.LOGIN);
|
||||
close();
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
if (data && data.data) {
|
||||
navigate(Links.LOGIN);
|
||||
close();
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<Menu shadow="md" width={200}>
|
||||
<Menu.Target>
|
||||
<Avatar color="blue" radius="xl" className="cursor-pointer">
|
||||
<IconUser size={20} />
|
||||
</Avatar>
|
||||
</Menu.Target>
|
||||
|
||||
return (
|
||||
<>
|
||||
<Menu shadow="md" width={200}>
|
||||
<Menu.Target>
|
||||
<Avatar color="blue" radius="xl" className="cursor-pointer">
|
||||
<IconUser size={20} />
|
||||
</Avatar>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Label>Account</Menu.Label>
|
||||
<Menu.Item onClick={open} leftSection={<IconSettings size={14} />}>
|
||||
Change password
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
component={Link}
|
||||
to={Links.GENERATE_KEYS}
|
||||
leftSection={<IconKey size={14} />}
|
||||
>
|
||||
Keys
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Dropdown>
|
||||
<Menu.Label>Account</Menu.Label>
|
||||
<Menu.Item onClick={open} leftSection={<IconSettings size={14} />}>
|
||||
Change password
|
||||
</Menu.Item>
|
||||
<Menu.Item component={Link} to={Links.GENERATE_KEYS} leftSection={<IconKey size={14} />}>
|
||||
Keys
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
component={Link}
|
||||
to={Links.CONFIGS}
|
||||
leftSection={<IconCode size={14} />}
|
||||
>
|
||||
Configs
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Divider />
|
||||
<Menu.Divider />
|
||||
|
||||
<Menu.Item onClick={handleLogout} color="red" leftSection={<IconLogout size={14} />}>
|
||||
Logout
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
<Menu.Item
|
||||
onClick={handleLogout}
|
||||
color="red"
|
||||
leftSection={<IconLogout size={14} />}
|
||||
>
|
||||
Logout
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
|
||||
<Modal className="relative" opened={opened} onClose={close} title="Change password" centered>
|
||||
<form onSubmit={form.onSubmit(handleSubmit)} className="flex flex-col gap-2.5">
|
||||
<PasswordInput size="sm" label="Current password" {...form.getInputProps('currentPassword')} />
|
||||
<PasswordInput size="sm" label="New password" {...form.getInputProps('newPassword')} />
|
||||
<PasswordInput size="sm" label="Confirm password" {...form.getInputProps('confirmPassword')} />
|
||||
<Button type="submit" fullWidth size="sm" mt="md">
|
||||
Change
|
||||
</Button>
|
||||
</form>
|
||||
<Modal
|
||||
className="relative"
|
||||
opened={opened}
|
||||
onClose={close}
|
||||
title="Change password"
|
||||
centered
|
||||
>
|
||||
<form
|
||||
onSubmit={form.onSubmit(handleSubmit)}
|
||||
className="flex flex-col gap-2.5"
|
||||
>
|
||||
<PasswordInput
|
||||
size="sm"
|
||||
label="Current password"
|
||||
{...form.getInputProps("currentPassword")}
|
||||
/>
|
||||
<PasswordInput
|
||||
size="sm"
|
||||
label="New password"
|
||||
{...form.getInputProps("newPassword")}
|
||||
/>
|
||||
<PasswordInput
|
||||
size="sm"
|
||||
label="Confirm password"
|
||||
{...form.getInputProps("confirmPassword")}
|
||||
/>
|
||||
<Button type="submit" fullWidth size="sm" mt="md">
|
||||
Change
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<LoadingOverlay visible={loading} zIndex={1000} overlayProps={{ blur: 2 }} />
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
<LoadingOverlay
|
||||
visible={loading}
|
||||
zIndex={1000}
|
||||
overlayProps={{ blur: 2 }}
|
||||
/>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,10 @@
|
|||
import { Box } from "@mantine/core";
|
||||
import MailsConfig from "../components/config/mails-config";
|
||||
|
||||
export default function Configs() {
|
||||
return (
|
||||
<Box className="flex">
|
||||
<MailsConfig />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
export { default as Dashboard } from './dashboard';
|
||||
export { default as Bids } from './bids';
|
||||
export { default as OutBidsLog } from './out-bids-log';
|
||||
export { default as Login } from './login';
|
||||
export { default as App } from './app';
|
||||
export { default as Dashboard } from "./dashboard";
|
||||
export { default as Bids } from "./bids";
|
||||
export { default as OutBidsLog } from "./out-bids-log";
|
||||
export { default as Login } from "./login";
|
||||
export { default as App } from "./app";
|
||||
export { default as Configs } from "./configs";
|
||||
|
|
|
|||
|
|
@ -1,70 +1,87 @@
|
|||
import { IconHammer, IconHome2, IconKey, IconMessage, IconOutlet, IconPageBreak, IconUserCheck } from '@tabler/icons-react';
|
||||
import { Bids, Dashboard, OutBidsLog } from '../pages';
|
||||
import WebBids from '../pages/web-bids';
|
||||
import SendMessageHistories from '../pages/send-message-histories';
|
||||
import Admins from '../pages/admins';
|
||||
import GenerateKeys from '../pages/generate-keys';
|
||||
import {
|
||||
IconHammer,
|
||||
IconHome2,
|
||||
IconKey,
|
||||
IconMessage,
|
||||
IconOutlet,
|
||||
IconPageBreak,
|
||||
IconSettings,
|
||||
IconUserCheck,
|
||||
} from "@tabler/icons-react";
|
||||
import { Bids, Configs, Dashboard, OutBidsLog } from "../pages";
|
||||
import WebBids from "../pages/web-bids";
|
||||
import SendMessageHistories from "../pages/send-message-histories";
|
||||
import Admins from "../pages/admins";
|
||||
import GenerateKeys from "../pages/generate-keys";
|
||||
export default class Links {
|
||||
public static DASHBOARD = '/dashboard';
|
||||
public static BIDS = '/bids';
|
||||
public static WEBS = '/webs';
|
||||
public static OUT_BIDS_LOG = '/out-bids-log';
|
||||
public static SEND_MESSAGE_HISTORIES = '/send-message-histories';
|
||||
public static GENERATE_KEYS = '/generate-keys';
|
||||
public static ADMINS = '/admins';
|
||||
public static DASHBOARD = "/dashboard";
|
||||
public static BIDS = "/bids";
|
||||
public static WEBS = "/webs";
|
||||
public static OUT_BIDS_LOG = "/out-bids-log";
|
||||
public static SEND_MESSAGE_HISTORIES = "/send-message-histories";
|
||||
public static GENERATE_KEYS = "/generate-keys";
|
||||
public static ADMINS = "/admins";
|
||||
public static CONFIGS = "/configs";
|
||||
|
||||
public static HOME = '/';
|
||||
public static LOGIN = '/login';
|
||||
public static HOME = "/";
|
||||
public static LOGIN = "/login";
|
||||
|
||||
public static MENUS = [
|
||||
{
|
||||
path: this.DASHBOARD,
|
||||
title: 'Dashboard',
|
||||
icon: IconHome2,
|
||||
element: Dashboard,
|
||||
show: true,
|
||||
},
|
||||
{
|
||||
path: this.ADMINS,
|
||||
title: 'Admins',
|
||||
icon: IconUserCheck,
|
||||
element: Admins,
|
||||
show: true,
|
||||
},
|
||||
{
|
||||
path: this.WEBS,
|
||||
title: 'Webs',
|
||||
icon: IconPageBreak,
|
||||
element: WebBids,
|
||||
show: true,
|
||||
},
|
||||
{
|
||||
path: this.BIDS,
|
||||
title: 'Bids',
|
||||
icon: IconHammer,
|
||||
element: Bids,
|
||||
show: true,
|
||||
},
|
||||
{
|
||||
path: this.OUT_BIDS_LOG,
|
||||
title: 'Out bids log',
|
||||
icon: IconOutlet,
|
||||
element: OutBidsLog,
|
||||
show: true,
|
||||
},
|
||||
{
|
||||
path: this.SEND_MESSAGE_HISTORIES,
|
||||
title: 'Send message histories',
|
||||
icon: IconMessage,
|
||||
element: SendMessageHistories,
|
||||
show: true,
|
||||
},
|
||||
{
|
||||
path: this.GENERATE_KEYS,
|
||||
title: 'Generate keys',
|
||||
icon: IconKey,
|
||||
element: GenerateKeys,
|
||||
show: false,
|
||||
},
|
||||
];
|
||||
public static MENUS = [
|
||||
{
|
||||
path: this.DASHBOARD,
|
||||
title: "Dashboard",
|
||||
icon: IconHome2,
|
||||
element: Dashboard,
|
||||
show: true,
|
||||
},
|
||||
{
|
||||
path: this.ADMINS,
|
||||
title: "Admins",
|
||||
icon: IconUserCheck,
|
||||
element: Admins,
|
||||
show: true,
|
||||
},
|
||||
{
|
||||
path: this.WEBS,
|
||||
title: "Webs",
|
||||
icon: IconPageBreak,
|
||||
element: WebBids,
|
||||
show: true,
|
||||
},
|
||||
{
|
||||
path: this.BIDS,
|
||||
title: "Bids",
|
||||
icon: IconHammer,
|
||||
element: Bids,
|
||||
show: true,
|
||||
},
|
||||
{
|
||||
path: this.OUT_BIDS_LOG,
|
||||
title: "Out bids log",
|
||||
icon: IconOutlet,
|
||||
element: OutBidsLog,
|
||||
show: true,
|
||||
},
|
||||
{
|
||||
path: this.SEND_MESSAGE_HISTORIES,
|
||||
title: "Send message histories",
|
||||
icon: IconMessage,
|
||||
element: SendMessageHistories,
|
||||
show: true,
|
||||
},
|
||||
{
|
||||
path: this.GENERATE_KEYS,
|
||||
title: "Generate keys",
|
||||
icon: IconKey,
|
||||
element: GenerateKeys,
|
||||
show: false,
|
||||
},
|
||||
{
|
||||
path: this.CONFIGS,
|
||||
title: "Configs",
|
||||
icon: IconSettings,
|
||||
element: Configs,
|
||||
show: false,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -82,6 +82,13 @@ export interface IBid extends ITimestamp {
|
|||
web_bid: IWebBid;
|
||||
}
|
||||
|
||||
export interface IConfig extends ITimestamp {
|
||||
id: number;
|
||||
key_name: string;
|
||||
value: string;
|
||||
type: "string" | "number";
|
||||
}
|
||||
|
||||
export interface IPermission extends ITimestamp {
|
||||
id: number;
|
||||
name: string;
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
{"createdAt":1747701959077}
|
||||
{"createdAt":1747812172479}
|
||||
|
|
@ -10,6 +10,7 @@
|
|||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
"@nestjs-modules/mailer": "^2.0.2",
|
||||
"@nestjs/bull": "^11.0.2",
|
||||
"@nestjs/common": "^10.0.0",
|
||||
"@nestjs/config": "^4.0.1",
|
||||
"@nestjs/core": "^10.0.0",
|
||||
|
|
@ -24,6 +25,7 @@
|
|||
"@nestjs/websockets": "^11.0.11",
|
||||
"axios": "^1.8.3",
|
||||
"bcrypt": "^5.1.1",
|
||||
"bull": "^4.16.5",
|
||||
"cheerio": "^1.0.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
|
|
@ -31,6 +33,7 @@
|
|||
"cookie-parser": "^1.4.7",
|
||||
"dayjs": "^1.11.13",
|
||||
"imap": "^0.8.19",
|
||||
"ioredis": "^5.6.1",
|
||||
"lodash": "^4.17.21",
|
||||
"moment": "^2.30.1",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
|
|
@ -1649,6 +1652,12 @@
|
|||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@ioredis/commands": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.2.0.tgz",
|
||||
"integrity": "sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@isaacs/cliui": {
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
||||
|
|
@ -2337,6 +2346,84 @@
|
|||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz",
|
||||
"integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz",
|
||||
"integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz",
|
||||
"integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz",
|
||||
"integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz",
|
||||
"integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz",
|
||||
"integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@nestjs-modules/mailer": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs-modules/mailer/-/mailer-2.0.2.tgz",
|
||||
|
|
@ -2412,6 +2499,34 @@
|
|||
"@pkgjs/parseargs": "^0.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/bull": {
|
||||
"version": "11.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/bull/-/bull-11.0.2.tgz",
|
||||
"integrity": "sha512-RjyP9JZUuLmMhmq1TMNIZqolkAd14az1jyXMMVki+C9dYvaMjWzBSwcZAtKs9Pk15Rm7qN1xn3R11aMV2Xv4gg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@nestjs/bull-shared": "^11.0.2",
|
||||
"tslib": "2.8.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0",
|
||||
"@nestjs/core": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0",
|
||||
"bull": "^3.3 || ^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/bull-shared": {
|
||||
"version": "11.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/bull-shared/-/bull-shared-11.0.2.tgz",
|
||||
"integrity": "sha512-dFlttJvBqIFD6M8JVFbkrR4Feb39OTAJPJpFVILU50NOJCM4qziRw3dSNG84Q3v+7/M6xUGMFdZRRGvBBKxoSA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "2.8.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@nestjs/common": "^10.0.0 || ^11.0.0",
|
||||
"@nestjs/core": "^10.0.0 || ^11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/cli": {
|
||||
"version": "10.4.9",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-10.4.9.tgz",
|
||||
|
|
@ -4663,6 +4778,33 @@
|
|||
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/bull": {
|
||||
"version": "4.16.5",
|
||||
"resolved": "https://registry.npmjs.org/bull/-/bull-4.16.5.tgz",
|
||||
"integrity": "sha512-lDsx2BzkKe7gkCYiT5Acj02DpTwDznl/VNN7Psn7M3USPG7Vs/BaClZJJTAG+ufAR9++N1/NiUTdaFBWDIl5TQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cron-parser": "^4.9.0",
|
||||
"get-port": "^5.1.1",
|
||||
"ioredis": "^5.3.2",
|
||||
"lodash": "^4.17.21",
|
||||
"msgpackr": "^1.11.2",
|
||||
"semver": "^7.5.2",
|
||||
"uuid": "^8.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/bull/node_modules/uuid": {
|
||||
"version": "8.3.2",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
|
||||
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "dist/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/busboy": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
|
||||
|
|
@ -5068,6 +5210,15 @@
|
|||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/cluster-key-slot": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
|
||||
"integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/co": {
|
||||
"version": "4.6.0",
|
||||
"resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz",
|
||||
|
|
@ -5400,6 +5551,18 @@
|
|||
"node": ">=18.x"
|
||||
}
|
||||
},
|
||||
"node_modules/cron-parser": {
|
||||
"version": "4.9.0",
|
||||
"resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz",
|
||||
"integrity": "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"luxon": "^3.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
|
|
@ -7316,7 +7479,6 @@
|
|||
"resolved": "https://registry.npmjs.org/get-port/-/get-port-5.1.1.tgz",
|
||||
"integrity": "sha512-g/Q1aTSDOxFpchXC4i8ZWvxA1lnPqx/JHqcpIw0/LX9T8x/GBbi6YnlN5nhaKIFkT8oFsscUKgDJYxfwfS6QsQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
|
|
@ -7904,6 +8066,30 @@
|
|||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ioredis": {
|
||||
"version": "5.6.1",
|
||||
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.6.1.tgz",
|
||||
"integrity": "sha512-UxC0Yv1Y4WRJiGQxQkP0hfdL0/5/6YvdfOOClRgJ0qppSarkhneSa6UvkMkms0AkdGimSH3Ikqm+6mkMmX7vGA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@ioredis/commands": "^1.1.1",
|
||||
"cluster-key-slot": "^1.1.0",
|
||||
"debug": "^4.3.4",
|
||||
"denque": "^2.1.0",
|
||||
"lodash.defaults": "^4.2.0",
|
||||
"lodash.isarguments": "^3.1.0",
|
||||
"redis-errors": "^1.2.0",
|
||||
"redis-parser": "^3.0.0",
|
||||
"standard-as-callback": "^2.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.22.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/ioredis"
|
||||
}
|
||||
},
|
||||
"node_modules/ipaddr.js": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||
|
|
@ -9506,12 +9692,24 @@
|
|||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.defaults": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
|
||||
"integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.includes": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
|
||||
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isarguments": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",
|
||||
"integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isboolean": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
|
||||
|
|
@ -10421,6 +10619,37 @@
|
|||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/msgpackr": {
|
||||
"version": "1.11.4",
|
||||
"resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.4.tgz",
|
||||
"integrity": "sha512-uaff7RG9VIC4jacFW9xzL3jc0iM32DNHe4jYVycBcjUePT/Klnfj7pqtWJt9khvDFizmjN2TlYniYmSS2LIaZg==",
|
||||
"license": "MIT",
|
||||
"optionalDependencies": {
|
||||
"msgpackr-extract": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/msgpackr-extract": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz",
|
||||
"integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"node-gyp-build-optional-packages": "5.2.2"
|
||||
},
|
||||
"bin": {
|
||||
"download-msgpackr-prebuilds": "bin/download-prebuilds.js"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3",
|
||||
"@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3",
|
||||
"@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3",
|
||||
"@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3",
|
||||
"@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3",
|
||||
"@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/multer": {
|
||||
"version": "1.4.5-lts.1",
|
||||
"resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.1.tgz",
|
||||
|
|
@ -10598,6 +10827,21 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/node-gyp-build-optional-packages": {
|
||||
"version": "5.2.2",
|
||||
"resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz",
|
||||
"integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"node-gyp-build-optional-packages": "bin.js",
|
||||
"node-gyp-build-optional-packages-optional": "optional.js",
|
||||
"node-gyp-build-optional-packages-test": "build-test.js"
|
||||
}
|
||||
},
|
||||
"node_modules/node-int64": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
|
||||
|
|
@ -11847,6 +12091,27 @@
|
|||
"node": ">= 12.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/redis-errors": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
|
||||
"integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/redis-parser": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",
|
||||
"integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"redis-errors": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/reflect-metadata": {
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
|
||||
|
|
@ -12979,6 +13244,12 @@
|
|||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/standard-as-callback": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
|
||||
"integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/statuses": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@nestjs-modules/mailer": "^2.0.2",
|
||||
"@nestjs/bull": "^11.0.2",
|
||||
"@nestjs/common": "^10.0.0",
|
||||
"@nestjs/config": "^4.0.1",
|
||||
"@nestjs/core": "^10.0.0",
|
||||
|
|
@ -40,6 +41,7 @@
|
|||
"@nestjs/websockets": "^11.0.11",
|
||||
"axios": "^1.8.3",
|
||||
"bcrypt": "^5.1.1",
|
||||
"bull": "^4.16.5",
|
||||
"cheerio": "^1.0.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
|
|
@ -47,6 +49,7 @@
|
|||
"cookie-parser": "^1.4.7",
|
||||
"dayjs": "^1.11.13",
|
||||
"imap": "^0.8.19",
|
||||
"ioredis": "^5.6.1",
|
||||
"lodash": "^4.17.21",
|
||||
"moment": "^2.30.1",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { BullModule } from '@nestjs/bull';
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { EventEmitterModule } from '@nestjs/event-emitter';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
|
|
@ -12,7 +14,17 @@ import { ScheduleModule } from '@nestjs/schedule';
|
|||
wildcard: true,
|
||||
global: true,
|
||||
}),
|
||||
ScheduleModule.forRoot()
|
||||
BullModule.forRoot({
|
||||
redis: {
|
||||
host: 'localhost',
|
||||
port: 6379,
|
||||
},
|
||||
}),
|
||||
BullModule.registerQueue({
|
||||
name: 'mail-queue',
|
||||
}),
|
||||
ScheduleModule.forRoot(),
|
||||
],
|
||||
exports: [BullModule],
|
||||
})
|
||||
export class AppConfigsModule {}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import { AdminDashboardController } from './controllers/admin/admin-dashboard.co
|
|||
import { TasksService } from './services/tasks.servise';
|
||||
import { ConfigsService } from './services/configs.service';
|
||||
import { Config } from './entities/configs.entity';
|
||||
import { AdminConfigsController } from './controllers/admin/admin-configs.controller';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
|
|
@ -55,6 +56,7 @@ import { Config } from './entities/configs.entity';
|
|||
AdminWebBidsController,
|
||||
AdminSendMessageHistoriesController,
|
||||
AdminDashboardController,
|
||||
AdminConfigsController,
|
||||
],
|
||||
providers: [
|
||||
BidsService,
|
||||
|
|
@ -76,6 +78,7 @@ import { Config } from './entities/configs.entity';
|
|||
SendMessageHistoriesService,
|
||||
BidsService,
|
||||
ConfigsService,
|
||||
DashboardService,
|
||||
],
|
||||
})
|
||||
export class BidsModule {}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,20 @@
|
|||
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
|
||||
import { DashboardService } from '../../services/dashboard.service';
|
||||
import { Config } from '../../entities/configs.entity';
|
||||
import { ConfigsService } from '../../services/configs.service';
|
||||
import { UpsertConfigDto } from '../../dto/config/upsert-config.dto';
|
||||
|
||||
@Controller('admin/configs')
|
||||
export class AdminConfigsController {
|
||||
constructor(private readonly configsService: ConfigsService) {}
|
||||
|
||||
@Post('upsert')
|
||||
async upsertConfig(@Body() data: UpsertConfigDto) {
|
||||
return await this.configsService.upsertConfig(data);
|
||||
}
|
||||
|
||||
@Get(':key')
|
||||
async getConfig(@Param('key') key: Config['key_name']) {
|
||||
return await this.configsService.getConfigRes(key);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
import { IsEnum, IsNumber, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class UpsertConfigDto {
|
||||
@IsString()
|
||||
key_name: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
value: string;
|
||||
|
||||
@IsEnum(['string', 'number'])
|
||||
type: 'string' | 'number';
|
||||
}
|
||||
|
|
@ -16,6 +16,8 @@ import { SendMessageHistoriesService } from './send-message-histories.service';
|
|||
import { NotificationService } from '@/modules/notification/notification.service';
|
||||
import { isTimeReached } from '@/ultils';
|
||||
import { BidsService } from './bids.service';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { Event } from '../utils/events';
|
||||
|
||||
@Injectable()
|
||||
export class BidHistoriesService {
|
||||
|
|
@ -28,6 +30,7 @@ export class BidHistoriesService {
|
|||
readonly sendMessageHistoriesService: SendMessageHistoriesService,
|
||||
private readonly notificationService: NotificationService,
|
||||
private readonly bidsService: BidsService,
|
||||
private eventEmitter: EventEmitter2,
|
||||
) {}
|
||||
|
||||
async index() {
|
||||
|
|
@ -38,6 +41,7 @@ export class BidHistoriesService {
|
|||
// Tìm thông tin bid từ database
|
||||
const bid = await this.bidsService.bidsRepo.findOne({
|
||||
where: { id: bid_id },
|
||||
relations: { web_bid: true },
|
||||
});
|
||||
|
||||
// Nếu không tìm thấy bid, trả về lỗi 404
|
||||
|
|
@ -104,6 +108,9 @@ export class BidHistoriesService {
|
|||
const botData = { ...bid, histories: response };
|
||||
this.botTelegramApi.sendBidInfo(botData);
|
||||
|
||||
// Send event thống place bid
|
||||
this.eventEmitter.emit(Event.BID_SUBMITED, botData);
|
||||
|
||||
// Lưu message đã gửi để theo dõi
|
||||
this.sendMessageHistoriesService.sendMessageRepo.save({
|
||||
message: this.botTelegramApi.formatBidMessage(botData),
|
||||
|
|
|
|||
|
|
@ -153,7 +153,10 @@ export class BidsService {
|
|||
}
|
||||
|
||||
async toggle(id: Bid['id']) {
|
||||
const bid = await this.bidsRepo.findOne({ where: { id } });
|
||||
const bid = await this.bidsRepo.findOne({
|
||||
where: { id },
|
||||
relations: { web_bid: true },
|
||||
});
|
||||
|
||||
if (!bid) {
|
||||
throw new NotFoundException(
|
||||
|
|
@ -301,7 +304,10 @@ export class BidsService {
|
|||
async outBid(id: Bid['id']) {
|
||||
const result = await this.bidsRepo.update(id, { status: 'out-bid' });
|
||||
|
||||
const bid = await this.bidsRepo.findOne({ where: { id } });
|
||||
const bid = await this.bidsRepo.findOne({
|
||||
where: { id },
|
||||
relations: { web_bid: true },
|
||||
});
|
||||
|
||||
if (!result) throw new BadRequestException(AppResponse.toResponse(false));
|
||||
|
||||
|
|
@ -353,7 +359,10 @@ export class BidsService {
|
|||
}
|
||||
|
||||
async updateStatusByPrice(id: Bid['id'], data: UpdateStatusByPriceDto) {
|
||||
const bid = await this.bidsRepo.findOne({ where: { id } });
|
||||
const bid = await this.bidsRepo.findOne({
|
||||
where: { id },
|
||||
relations: { web_bid: true },
|
||||
});
|
||||
|
||||
if (!bid)
|
||||
throw new NotFoundException(
|
||||
|
|
|
|||
|
|
@ -1,8 +1,14 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
BadRequestException,
|
||||
HttpStatus,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Config } from '../entities/configs.entity';
|
||||
import AppResponse from '@/response/app-response';
|
||||
|
||||
@Injectable()
|
||||
export class ConfigsService {
|
||||
|
|
@ -29,4 +35,41 @@ export class ConfigsService {
|
|||
'key_name',
|
||||
]);
|
||||
}
|
||||
|
||||
async getConfigRes(key_name: string) {
|
||||
const result = await this.getConfig(
|
||||
key_name as keyof typeof ConfigsService.CONFIG_KEYS,
|
||||
);
|
||||
|
||||
if (!result)
|
||||
throw new NotFoundException(
|
||||
AppResponse.toResponse(null, {
|
||||
message: 'Config key name not found',
|
||||
status_code: HttpStatus.NOT_FOUND,
|
||||
}),
|
||||
);
|
||||
|
||||
return AppResponse.toResponse(result);
|
||||
}
|
||||
|
||||
async upsertConfig(data: Partial<Config>) {
|
||||
let response = null;
|
||||
|
||||
const prevConfig = await this.configRepo.findOne({
|
||||
where: { key_name: data.key_name },
|
||||
});
|
||||
|
||||
if (!prevConfig) {
|
||||
response = await this.configRepo.save(data);
|
||||
} else {
|
||||
response = await this.configRepo.update(
|
||||
{ key_name: data.key_name },
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
if (!response) throw new BadRequestException(AppResponse.toResponse(false));
|
||||
|
||||
return AppResponse.toResponse(!!response);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ export class Event {
|
|||
public static ADMIN_BIDS_UPDATED = 'adminBidsUpdated';
|
||||
public static WEB_UPDATED = 'webUpdated';
|
||||
public static LOGIN_STATUS = 'login-status';
|
||||
public static BID_SUBMITED = 'bid-submited';
|
||||
public static BID_STATUS = 'bid-status';
|
||||
|
||||
public static verifyCode(data: WebBid) {
|
||||
return `${this.VERIFY_CODE}.${data.origin_url}`;
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@ import { MailerModule } from '@nestjs-modules/mailer';
|
|||
import { Module } from '@nestjs/common';
|
||||
import { MailsService } from './services/mails.service';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
|
||||
import { BullModule } from '@nestjs/bull';
|
||||
import { MailProcessor } from './process/mail.processor';
|
||||
@Module({
|
||||
imports: [
|
||||
MailerModule.forRootAsync({
|
||||
|
|
@ -21,7 +22,7 @@ import { ConfigModule, ConfigService } from '@nestjs/config';
|
|||
inject: [ConfigService],
|
||||
}),
|
||||
],
|
||||
providers: [MailsService],
|
||||
providers: [MailsService, MailProcessor],
|
||||
exports: [MailsService],
|
||||
})
|
||||
export class MailsModule {}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,16 @@
|
|||
// processors/mail.processor.ts
|
||||
import { Process, Processor } from '@nestjs/bull';
|
||||
import { Job } from 'bull';
|
||||
import { MailsService } from '../services/mails.service';
|
||||
|
||||
@Processor('mail-queue')
|
||||
export class MailProcessor {
|
||||
constructor(private readonly mailsService: MailsService) {}
|
||||
|
||||
@Process('send-mail')
|
||||
async handleSendMail(job: Job) {
|
||||
const { to, subject, html } = job.data;
|
||||
|
||||
await this.mailsService.sendPlainHtml(to, subject, html);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,11 +1,22 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { MailerService } from '@nestjs-modules/mailer';
|
||||
import { ScrapItem } from '@/modules/scraps/entities/scrap-item.entity';
|
||||
import { extractDomain } from '@/ultils';
|
||||
import {
|
||||
extractDomain,
|
||||
extractDomainSmart,
|
||||
formatEndTime,
|
||||
isTimeReached,
|
||||
} from '@/ultils';
|
||||
import { Bid } from '@/modules/bids/entities/bid.entity';
|
||||
import { InjectQueue } from '@nestjs/bull';
|
||||
import { Queue } from 'bull';
|
||||
|
||||
@Injectable()
|
||||
export class MailsService {
|
||||
constructor(private readonly mailerService: MailerService) {}
|
||||
constructor(
|
||||
private readonly mailerService: MailerService,
|
||||
@InjectQueue('mail-queue') private mailQueue: Queue,
|
||||
) {}
|
||||
|
||||
async sendPlainText(to: string, subject: string, content: string) {
|
||||
await this.mailerService.sendMail({
|
||||
|
|
@ -15,6 +26,14 @@ export class MailsService {
|
|||
});
|
||||
}
|
||||
|
||||
async sendHtmlMailJob(mailData: {
|
||||
to: string;
|
||||
subject: string;
|
||||
html: string;
|
||||
}) {
|
||||
await this.mailQueue.add('send-mail', mailData);
|
||||
}
|
||||
|
||||
async sendPlainHtml(to: string, subject: string, html: string) {
|
||||
const emails = to
|
||||
.split(',')
|
||||
|
|
@ -33,17 +52,34 @@ export class MailsService {
|
|||
}
|
||||
|
||||
generateProductTableHTML(products: ScrapItem[]): string {
|
||||
if (!products.length) {
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>Products</title>
|
||||
</head>
|
||||
<body style="font-family: sans-serif; background: #f8f9fa; padding: 20px;">
|
||||
<h2 style="text-align: center; color: #333;">Product Listing</h2>
|
||||
<p style="text-align: center; color: #666;">No matching products found for your keywords today.</p>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
|
||||
const rows = products
|
||||
.map(
|
||||
(p) => `
|
||||
<tr>
|
||||
<td><img src="${p.image_url}" alt="Product Image" style="height: 40px; object-fit: contain; border-radius: 4px;" /></td>
|
||||
<td>${p.name}</td>
|
||||
<td style="font-weight: bold; color: #e03131;">$${p.current_price}</td>
|
||||
<td><a href="${p.url}" target="_blank" style="color: #007bff;">View</a></td>
|
||||
<td>${extractDomain(p.scrap_config.web_bid.origin_url)}</td>
|
||||
</tr>
|
||||
`,
|
||||
<tr>
|
||||
<td><img src="${p.image_url}" alt="Product Image" style="height: 40px; object-fit: contain; border-radius: 4px;" /></td>
|
||||
<td>${p.name}</td>
|
||||
<td style="font-weight: bold; color: #e03131;">${p.current_price ? '$' + p.current_price : 'None'}</td>
|
||||
<td><a href="${p.url}" target="_blank" style="color: #007bff;">View</a></td>
|
||||
<td>${extractDomainSmart(p.scrap_config.web_bid.origin_url)}</td>
|
||||
</tr>
|
||||
`,
|
||||
)
|
||||
.join('');
|
||||
|
||||
|
|
@ -78,12 +114,197 @@ export class MailsService {
|
|||
`;
|
||||
}
|
||||
|
||||
async sendWithTemplate(to: string, subject: string, payload: any) {
|
||||
await this.mailerService.sendMail({
|
||||
to,
|
||||
subject,
|
||||
template: './welcome', // đường dẫn tương đối trong /templates
|
||||
context: payload, // dữ liệu cho template
|
||||
});
|
||||
getAuctionStatusEmailContent(bid: Bid): string {
|
||||
const webname = extractDomain(bid.web_bid.origin_url);
|
||||
const title = `[${webname}] ${bid.name || 'Unnamed Item'}`;
|
||||
const endTime = formatEndTime(bid.close_time, false);
|
||||
const competitor = `$${bid.current_price}`;
|
||||
const max = `$${bid.max_price}`;
|
||||
const submitted = `$${bid.max_price}`;
|
||||
const nextBid = bid.max_price + bid.plus_price;
|
||||
|
||||
const cardStyle = `
|
||||
max-width: 600px;
|
||||
margin: 20px auto;
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background: #ffffff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
|
||||
color: #333;
|
||||
padding: 20px;
|
||||
`;
|
||||
|
||||
const headerStyle = (color: string) =>
|
||||
`font-size: 22px; font-weight: 700; color: ${color}; margin-bottom: 15px;`;
|
||||
|
||||
const labelStyle = `font-weight: 600; width: 120px; display: inline-block; color: #555;`;
|
||||
const valueStyle = `color: #222;`;
|
||||
|
||||
const renderRow = (label: string, value: string) =>
|
||||
`<p><span style="${labelStyle}">${label}:</span> <span style="${valueStyle}">${value}</span></p>`;
|
||||
|
||||
switch (bid.status) {
|
||||
case 'biding':
|
||||
return `
|
||||
<div style="${cardStyle}">
|
||||
<h2 style="${headerStyle('#2c7a7b')}">✅ Auto Bid Started</h2>
|
||||
${renderRow('Title', title)}
|
||||
${renderRow('Max', max)}
|
||||
${renderRow('End time', endTime)}
|
||||
${renderRow('Competitor', competitor)}
|
||||
${renderRow('Bid submitted', submitted)}
|
||||
</div>
|
||||
`;
|
||||
|
||||
case 'out-bid': {
|
||||
const overLimit = bid.current_price >= nextBid;
|
||||
const belowReserve = bid.reserve_price > nextBid;
|
||||
const timeExtended = bid.close_time ? 'Time extended' : 'No extension';
|
||||
|
||||
if (isTimeReached(bid.close_time)) {
|
||||
return `
|
||||
<div style="${cardStyle}">
|
||||
<h2 style="${headerStyle('#718096')}">⏳ Auction Ended</h2>
|
||||
${renderRow('Title', title)}
|
||||
${renderRow('End time', endTime)}
|
||||
${renderRow('Final price', competitor)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (overLimit || belowReserve) {
|
||||
return `
|
||||
<div style="${cardStyle}">
|
||||
<h2 style="${headerStyle('#dd6b20')}">⚠️ Outbid (${timeExtended})</h2>
|
||||
${renderRow('Title', title)}
|
||||
${renderRow('Competitor', competitor)}
|
||||
${renderRow('Max', max)}
|
||||
${renderRow('Next bid at', `$${nextBid}`)}
|
||||
${renderRow('End time', endTime)}
|
||||
<p style="color:#c05621; font-weight: 600;">⚠️ Current bid exceeds your max bid.</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return `
|
||||
<div style="${cardStyle}">
|
||||
<h2 style="${headerStyle('#e53e3e')}">🛑 Auction Canceled (${timeExtended})</h2>
|
||||
${renderRow('Title', title)}
|
||||
${renderRow('Competitor', competitor)}
|
||||
${renderRow('Max', max)}
|
||||
${renderRow('Next bid at', `$${nextBid}`)}
|
||||
${renderRow('End time', endTime)}
|
||||
<p style="color:#9b2c2c; font-weight: 600;">🛑 Auction has been canceled.</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
case 'win-bid':
|
||||
return `
|
||||
<div style="${cardStyle}">
|
||||
<h2 style="${headerStyle('#2b6cb0')}">🎉 You Won!</h2>
|
||||
${renderRow('Title', title)}
|
||||
${renderRow('Price won', `$${bid.current_price}`)}
|
||||
${renderRow('Max', max)}
|
||||
</div>
|
||||
`;
|
||||
|
||||
default:
|
||||
return `
|
||||
<div style="${cardStyle}">
|
||||
<h2 style="${headerStyle('#718096')}">❓ Unknown Status</h2>
|
||||
${renderRow('Title', title)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
getBidSubmittedEmailContent(bid: Bid): string {
|
||||
const webname = extractDomain(bid.web_bid.origin_url);
|
||||
const title = `[${webname}] ${bid.name || 'Unnamed Item'}`;
|
||||
const endTime = formatEndTime(bid.close_time, false);
|
||||
const competitor = `$${bid.current_price}`;
|
||||
const max = `$${bid.max_price}`;
|
||||
const submitted = `$${bid.max_price}`;
|
||||
const maxReached = bid.max_price <= bid.max_price;
|
||||
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
background: #f9f9f9;
|
||||
color: #333;
|
||||
padding: 20px;
|
||||
}
|
||||
.container {
|
||||
background: #fff;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
max-width: 600px;
|
||||
margin: auto;
|
||||
}
|
||||
h2 {
|
||||
color: #007bff;
|
||||
text-align: center;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 15px;
|
||||
}
|
||||
th, td {
|
||||
padding: 10px;
|
||||
border-bottom: 1px solid #ddd;
|
||||
text-align: left;
|
||||
}
|
||||
th {
|
||||
background-color: #f1f1f1;
|
||||
color: #555;
|
||||
}
|
||||
.highlight {
|
||||
color: #e03131;
|
||||
font-weight: bold;
|
||||
}
|
||||
.max-reach {
|
||||
color: #d6336c;
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h2>Bid Submitted${bid.close_time ? ', Time extended' : ', No extension'}${maxReached ? ' <span class="max-reach">* MAX REACH *</span>' : ''}</h2>
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>Title</th>
|
||||
<td>${title}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Competitor</th>
|
||||
<td>${competitor}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Bid Submitted</th>
|
||||
<td>${submitted} ${maxReached ? '<span class="max-reach">(<b>***MAXIMUM REACH***</b>)</span>' : ''}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Max</th>
|
||||
<td class="highlight">${max}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>End Time</th>
|
||||
<td>${endTime}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
export const NAME_EVENTS = {
|
||||
BID_STATUS: 'notify.bid-status',
|
||||
};
|
||||
// export const NAME_EVENTS = {
|
||||
// BID_STATUS: 'notify.bid-status',
|
||||
// };
|
||||
|
|
|
|||
|
|
@ -20,7 +20,8 @@ export class ClientNotificationController {
|
|||
@Post('test')
|
||||
async test() {
|
||||
const bid = await this.bidsService.bidsRepo.findOne({
|
||||
where: { lot_id: '26077023' },
|
||||
where: { lot_id: '23755862' },
|
||||
relations: { web_bid: true },
|
||||
});
|
||||
|
||||
return await this.notifyService.emitBidStatus({
|
||||
|
|
|
|||
|
|
@ -1,16 +1,23 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { NAME_EVENTS } from '../constants';
|
||||
import { Bid } from '@/modules/bids/entities/bid.entity';
|
||||
import { Notification } from '../entities/notification.entity';
|
||||
import { BotTelegramApi } from '@/modules/bids/apis/bot-telegram.api';
|
||||
import { MailsService } from '@/modules/mails/services/mails.service';
|
||||
import { ConfigsService } from '@/modules/bids/services/configs.service';
|
||||
import * as moment from 'moment';
|
||||
import { Event } from '@/modules/bids/utils/events';
|
||||
|
||||
@Injectable()
|
||||
export class AdminNotificationListener {
|
||||
constructor(private readonly botTelegramApi: BotTelegramApi) {}
|
||||
constructor(
|
||||
private readonly botTelegramApi: BotTelegramApi,
|
||||
private readonly mailsService: MailsService,
|
||||
private readonly configsSerice: ConfigsService,
|
||||
) {}
|
||||
|
||||
@OnEvent(NAME_EVENTS.BID_STATUS)
|
||||
handleBidStatus({
|
||||
@OnEvent(Event.BID_STATUS)
|
||||
async handleBidStatus({
|
||||
bid,
|
||||
notification,
|
||||
}: {
|
||||
|
|
@ -20,5 +27,30 @@ export class AdminNotificationListener {
|
|||
if (JSON.parse(notification.send_to).length <= 0) return;
|
||||
|
||||
this.botTelegramApi.sendMessage(notification.message);
|
||||
|
||||
const mails =
|
||||
(await this.configsSerice.getConfig('MAIL_SCRAP_REPORT')).value || '';
|
||||
|
||||
this.mailsService.sendHtmlMailJob({
|
||||
to: mails,
|
||||
html: this.mailsService.getAuctionStatusEmailContent(bid),
|
||||
subject:
|
||||
'Report Auto Auctions System ' +
|
||||
moment(new Date()).format('YYYY-MM-DD HH:mm'),
|
||||
});
|
||||
}
|
||||
|
||||
@OnEvent(Event.BID_SUBMITED)
|
||||
async handleBidSubmited(bid: Bid) {
|
||||
const mails =
|
||||
(await this.configsSerice.getConfig('MAIL_SCRAP_REPORT')).value || '';
|
||||
|
||||
this.mailsService.sendHtmlMailJob({
|
||||
to: mails,
|
||||
html: this.mailsService.getBidSubmittedEmailContent(bid),
|
||||
subject:
|
||||
'Report Auto Auctions System ' +
|
||||
moment(new Date()).format('YYYY-MM-DD HH:mm'),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,11 +8,13 @@ import { AdminNotificationListener } from './listeners/admin-notification.listen
|
|||
import { NotificationService } from './notification.service';
|
||||
import { SendMessageHistoriesService } from '../bids/services/send-message-histories.service';
|
||||
import { SendMessageHistory } from '../bids/entities/send-message-histories.entity';
|
||||
import { MailsModule } from '../mails/mails.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
forwardRef(() => BidsModule),
|
||||
TypeOrmModule.forFeature([Notification, SendMessageHistory]),
|
||||
MailsModule,
|
||||
],
|
||||
controllers: [NotificationController, ClientNotificationController],
|
||||
providers: [NotificationService, AdminNotificationListener],
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { Bid } from '../bids/entities/bid.entity';
|
||||
import { NAME_EVENTS } from './constants';
|
||||
import { BotTelegramApi } from '../bids/apis/bot-telegram.api';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Notification } from './entities/notification.entity';
|
||||
|
|
@ -17,6 +16,7 @@ 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';
|
||||
import { Event } from '../bids/utils/events';
|
||||
|
||||
@Injectable()
|
||||
export class NotificationService {
|
||||
|
|
@ -116,7 +116,7 @@ export class NotificationService {
|
|||
message: notification.message,
|
||||
type: bid.status,
|
||||
max_price: bid.max_price,
|
||||
reserve_price: bid.reserve_price
|
||||
reserve_price: bid.reserve_price,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -127,13 +127,13 @@ export class NotificationService {
|
|||
message: notification.message,
|
||||
type: bid.status,
|
||||
max_price: bid.max_price,
|
||||
reserve_price: bid.reserve_price
|
||||
reserve_price: bid.reserve_price,
|
||||
});
|
||||
|
||||
this.eventEmitter.emit(NAME_EVENTS.BID_STATUS, {
|
||||
this.eventEmitter.emit(Event.BID_STATUS, {
|
||||
bid: {
|
||||
...bid,
|
||||
status: 'out-bid',
|
||||
// status: 'out-bid',
|
||||
},
|
||||
notification,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,12 @@
|
|||
import { Controller, Get } from '@nestjs/common';
|
||||
import { ScrapConfigsService } from '../../services/scrap-config.service';
|
||||
|
||||
@Controller('scrap-configs')
|
||||
export class ClientScrapConfigsController {
|
||||
constructor(private readonly scrapConfigsService: ScrapConfigsService) {}
|
||||
|
||||
@Get()
|
||||
async clientGetScrapeConfigs() {
|
||||
return await this.scrapConfigsService.clientGetScrapeConfigs();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
import { Body, Controller, Get, Post } from '@nestjs/common';
|
||||
import { ScrapConfigsService } from '../../services/scrap-config.service';
|
||||
import { ScrapItemsService } from '../../services/scrap-item-config.service';
|
||||
import { UpsertScrapItemDto } from '../../dto/scrap-items/upsert-scrap-item.dto';
|
||||
import { ScrapItem } from '../../entities/scrap-item.entity';
|
||||
|
||||
@Controller('scrap-items')
|
||||
export class ClientScrapItemsController {
|
||||
constructor(private readonly scrapItemsService: ScrapItemsService) {}
|
||||
|
||||
@Post('upsert')
|
||||
async upsertScrapItems(@Body() data: UpsertScrapItemDto[]) {
|
||||
return await this.scrapItemsService.upsertScrapItemsRes(
|
||||
data as ScrapItem[],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,22 +1,12 @@
|
|||
import { MailsService } from '@/modules/mails/services/mails.service';
|
||||
import { Body, Controller, Get, Param, Post, Put } from '@nestjs/common';
|
||||
import { Between, IsNull, Not } from 'typeorm';
|
||||
import { Body, Controller, Param, Post, Put } from '@nestjs/common';
|
||||
import { CreateScrapConfigDto } from '../dto/scrap-config/create-scrap-config';
|
||||
import { UpdateScrapConfigDto } from '../dto/scrap-config/update-scrap-config';
|
||||
import { ScrapConfig } from '../entities/scrap-config.entity';
|
||||
import { ScrapConfigsService } from '../services/scrap-config.service';
|
||||
import { ScrapItemsService } from '../services/scrap-item-config.service';
|
||||
import { ConfigsService } from '@/modules/bids/services/configs.service';
|
||||
import * as moment from 'moment';
|
||||
|
||||
@Controller('admin/scrap-configs')
|
||||
export class ScrapConfigsController {
|
||||
constructor(
|
||||
private readonly scrapConfigsService: ScrapConfigsService,
|
||||
private readonly scrapItemsService: ScrapItemsService,
|
||||
private readonly mailsService: MailsService,
|
||||
private readonly configsSerivce: ConfigsService,
|
||||
) {}
|
||||
constructor(private readonly scrapConfigsService: ScrapConfigsService) {}
|
||||
|
||||
@Post()
|
||||
async create(@Body() data: CreateScrapConfigDto) {
|
||||
|
|
@ -30,56 +20,4 @@ export class ScrapConfigsController {
|
|||
) {
|
||||
return await this.scrapConfigsService.update(id, data);
|
||||
}
|
||||
|
||||
@Get()
|
||||
async test() {
|
||||
const mails = (await this.configsSerivce.getConfig('MAIL_SCRAP_REPORT'))
|
||||
.value;
|
||||
|
||||
if (!mails) return;
|
||||
|
||||
const scrapConfigs = await this.scrapConfigsService.scrapConfigRepo.find({
|
||||
where: {
|
||||
search_url: Not(IsNull()),
|
||||
keywords: Not(IsNull()),
|
||||
},
|
||||
relations: {
|
||||
web_bid: true,
|
||||
},
|
||||
});
|
||||
const models = this.scrapConfigsService.scrapModels(scrapConfigs);
|
||||
await Promise.allSettled(
|
||||
models.map(async (item) => {
|
||||
await item.action();
|
||||
for (const key of Object.keys(item.results)) {
|
||||
const dataArray = item.results[key];
|
||||
const result =
|
||||
await this.scrapItemsService.upsertScrapItems(dataArray);
|
||||
console.log(result);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const startOfDay = new Date();
|
||||
startOfDay.setHours(0, 0, 0, 0);
|
||||
|
||||
const endOfDay = new Date();
|
||||
endOfDay.setHours(23, 59, 59, 999);
|
||||
|
||||
const data = await this.scrapItemsService.scrapItemRepo.find({
|
||||
where: {
|
||||
updated_at: Between(startOfDay, endOfDay),
|
||||
},
|
||||
relations: { scrap_config: { web_bid: true } },
|
||||
order: { updated_at: 'DESC' },
|
||||
});
|
||||
|
||||
await this.mailsService.sendPlainHtml(
|
||||
mails,
|
||||
`Auction Items Matching Your Keywords – Daily Update ${moment(new Date()).format('YYYY-MM-DD HH:mm')}`,
|
||||
this.mailsService.generateProductTableHTML(data),
|
||||
);
|
||||
|
||||
return { a: 'abc' };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,18 @@
|
|||
import { IsNumber, IsString, IsUrl } from 'class-validator';
|
||||
|
||||
export class UpsertScrapItemDto {
|
||||
@IsUrl()
|
||||
url: string;
|
||||
@IsString()
|
||||
image_url: string;
|
||||
@IsString()
|
||||
name: string;
|
||||
@IsString()
|
||||
keyword: string;
|
||||
@IsNumber()
|
||||
current_price: number;
|
||||
@IsNumber()
|
||||
scrap_config_id: number;
|
||||
@IsString()
|
||||
model: string;
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
import { ScrapItem } from '../entities/scrap-item.entity';
|
||||
|
||||
export interface ScrapInterface {
|
||||
getItemsInHtml: (data: {
|
||||
html: string;
|
||||
keyword: string;
|
||||
}) => Promise<ScrapItem[]>;
|
||||
|
||||
action: () => Promise<void>;
|
||||
}
|
||||
|
|
@ -1,61 +0,0 @@
|
|||
import { ScrapConfig } from '../entities/scrap-config.entity';
|
||||
import { ScrapItem } from '../entities/scrap-item.entity';
|
||||
import { ScrapInterface } from './scrap-interface';
|
||||
import { Element } from 'domhandler';
|
||||
export class ScrapModel implements ScrapInterface {
|
||||
protected keywords: string;
|
||||
protected search_url: string;
|
||||
protected scrap_config_id: ScrapConfig['id'];
|
||||
public results: Record<string, ScrapItem[]> = {};
|
||||
|
||||
constructor({
|
||||
keywords,
|
||||
search_url,
|
||||
scrap_config_id,
|
||||
}: {
|
||||
keywords: string;
|
||||
search_url: string;
|
||||
scrap_config_id: ScrapConfig['id'];
|
||||
}) {
|
||||
this.keywords = keywords;
|
||||
this.search_url = search_url;
|
||||
this.scrap_config_id = scrap_config_id;
|
||||
}
|
||||
|
||||
protected buildUrlWithKey(rawUrl: string, keyword: string) {
|
||||
return rawUrl.replaceAll('{{keyword}}', keyword);
|
||||
}
|
||||
|
||||
protected extractUrls(): { url: string; keyword: string }[] {
|
||||
// Tách từng key ra từ một chuỗi keywords
|
||||
const keywordList = this.keywords.split(', ');
|
||||
|
||||
// Dừng hàm nếu không có key nào
|
||||
if (keywordList.length <= 0) return [];
|
||||
|
||||
// Lập qua từng key để lấy url
|
||||
return keywordList.map((keyword) => {
|
||||
return {
|
||||
url: this.buildUrlWithKey(this.search_url, keyword),
|
||||
keyword,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
action: () => Promise<void>;
|
||||
|
||||
getInfoItems: (
|
||||
data: { name: string; el: Element }[],
|
||||
) => Record<string, string>[];
|
||||
|
||||
getItemsInHtml: (data: {
|
||||
html: string;
|
||||
keyword: string;
|
||||
}) => Promise<ScrapItem[]>;
|
||||
|
||||
protected filterItemByKeyword = (keyword: string, data: ScrapItem[]) => {
|
||||
return data.filter((item) =>
|
||||
item.name.toLowerCase().includes(keyword.toLowerCase()),
|
||||
);
|
||||
};
|
||||
}
|
||||
|
|
@ -1,83 +0,0 @@
|
|||
import axios from 'axios';
|
||||
import { ScrapInterface } from '../scrap-interface';
|
||||
import { ScrapModel } from '../scrap-model';
|
||||
import * as cheerio from 'cheerio';
|
||||
import { Element } from 'domhandler';
|
||||
import { ScrapItem } from '@/modules/scraps/entities/scrap-item.entity';
|
||||
import { extractModelId, extractNumber } from '@/ultils';
|
||||
export class GraysScrapModel extends ScrapModel {
|
||||
action = async () => {
|
||||
const urls = this.extractUrls();
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
urls.map(async (item) => ({
|
||||
html: (await axios.get(item.url)).data,
|
||||
keyword: item.keyword,
|
||||
})),
|
||||
);
|
||||
|
||||
const htmlsData = results
|
||||
.filter((res) => res.status === 'fulfilled')
|
||||
.map((res) => (res as PromiseFulfilledResult<any>).value);
|
||||
|
||||
await Promise.all(
|
||||
htmlsData.map(async (cur) => {
|
||||
try {
|
||||
const data = await this.getItemsInHtml(cur);
|
||||
const results = this.filterItemByKeyword(cur.keyword, data);
|
||||
|
||||
this.results[cur.keyword] = results; // hoặc push như gợi ý trên
|
||||
return results;
|
||||
} catch (err) {
|
||||
console.error(`❌ Error with keyword ${cur.keyword}:`, err);
|
||||
return []; // fallback
|
||||
}
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
getPriceByEl = ($: cheerio.CheerioAPI, el: Element): number | null => {
|
||||
const selectors = [
|
||||
'.sc-ijDOKB.sc-bStcSt.ikmQUw.eEycyP', // Single product price
|
||||
'.sc-ijDOKB.ikmQUw', // Multiple product price
|
||||
];
|
||||
|
||||
for (const selector of selectors) {
|
||||
const text = $(el).find(selector).text();
|
||||
const price = extractNumber(text);
|
||||
if (price) return price;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
getItemsInHtml = async ({
|
||||
html,
|
||||
keyword,
|
||||
}: {
|
||||
html: string;
|
||||
keyword: string;
|
||||
}) => {
|
||||
const $ = cheerio.load(html);
|
||||
|
||||
const container = $('.sc-102aeaf3-1.eYPitT');
|
||||
|
||||
const items = container.children('div').toArray();
|
||||
|
||||
const results = items.map((el) => {
|
||||
const url = $(el).find('.sc-pKqro.sc-gFnajm.gqkMpZ.dzWUkJ').attr('href');
|
||||
|
||||
return {
|
||||
name: $(el).find('.sc-jlGgGc.dJRywx').text().trim(),
|
||||
image_url: $(el).find('img.sc-gtJxfw.jbgdlx').attr('src'),
|
||||
model: extractModelId(url),
|
||||
keyword,
|
||||
url,
|
||||
current_price: this.getPriceByEl($, el),
|
||||
scrap_config_id: this.scrap_config_id,
|
||||
} as ScrapItem;
|
||||
});
|
||||
|
||||
return results;
|
||||
};
|
||||
}
|
||||
|
|
@ -1,108 +0,0 @@
|
|||
import { ScrapItem } from '@/modules/scraps/entities/scrap-item.entity';
|
||||
import { extractModelId, extractNumber } from '@/ultils';
|
||||
import axios from 'axios';
|
||||
import * as cheerio from 'cheerio';
|
||||
import { Element } from 'domhandler';
|
||||
import { ScrapModel } from '../scrap-model';
|
||||
|
||||
export class LangtonsScrapModel extends ScrapModel {
|
||||
action = async () => {
|
||||
const urls = this.extractUrls();
|
||||
|
||||
console.log({ urls });
|
||||
const results = await Promise.allSettled(
|
||||
urls.map(async (item) => ({
|
||||
html: (
|
||||
await axios.get(item.url, {
|
||||
headers: {
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36',
|
||||
Accept: 'text/html',
|
||||
},
|
||||
maxRedirects: 5, // default là 5, bạn có thể tăng lên
|
||||
})
|
||||
).data,
|
||||
keyword: item.keyword,
|
||||
})),
|
||||
);
|
||||
|
||||
console.log(
|
||||
results.map((r) => ({
|
||||
status: r.status,
|
||||
reason:
|
||||
r.status === 'rejected'
|
||||
? (r as PromiseRejectedResult).reason.message
|
||||
: undefined,
|
||||
})),
|
||||
);
|
||||
|
||||
const htmlsData = results
|
||||
.filter((res) => res.status === 'fulfilled')
|
||||
.map((res) => (res as PromiseFulfilledResult<any>).value);
|
||||
|
||||
console.log({ htmlsData });
|
||||
|
||||
await Promise.all(
|
||||
htmlsData.map(async (cur) => {
|
||||
try {
|
||||
const data = await this.getItemsInHtml(cur);
|
||||
const results = this.filterItemByKeyword(cur.keyword, data);
|
||||
|
||||
this.results[cur.keyword] = results; // hoặc push như gợi ý trên
|
||||
return results;
|
||||
} catch (err) {
|
||||
console.error(`❌ Error with keyword ${cur.keyword}:`, err);
|
||||
return []; // fallback
|
||||
}
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
getPriceByEl = ($: cheerio.CheerioAPI, el: Element): number | null => {
|
||||
const selectors = [
|
||||
'.sc-ijDOKB.sc-bStcSt.ikmQUw.eEycyP', // Single product price
|
||||
'.sc-ijDOKB.ikmQUw', // Multiple product price
|
||||
];
|
||||
|
||||
for (const selector of selectors) {
|
||||
const text = $(el).find(selector).text();
|
||||
const price = extractNumber(text);
|
||||
if (price) return price;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
getItemsInHtml = async ({
|
||||
html,
|
||||
keyword,
|
||||
}: {
|
||||
html: string;
|
||||
keyword: string;
|
||||
}) => {
|
||||
const $ = cheerio.load(html);
|
||||
|
||||
const container = $('.row.product-grid.grid-view');
|
||||
|
||||
const items = container.children('div').toArray();
|
||||
|
||||
const results = items.map((el) => {
|
||||
const url = $(el).find('a.js-pdp-link.pdp-link-anchor').attr('href');
|
||||
|
||||
return {
|
||||
name: $(el).find('.link.js-pdp-link').text().trim(),
|
||||
image_url: $(el).find('img.tile-image.loaded').attr('src'),
|
||||
model: extractModelId(url),
|
||||
keyword,
|
||||
url,
|
||||
current_price: extractNumber(
|
||||
$(el).find('div.max-bid-price.price').text(),
|
||||
),
|
||||
scrap_config_id: this.scrap_config_id,
|
||||
} as ScrapItem;
|
||||
});
|
||||
|
||||
console.log({ results });
|
||||
return results;
|
||||
};
|
||||
}
|
||||
|
|
@ -8,6 +8,8 @@ import { TasksService } from './services/tasks.service';
|
|||
import { ScrapItemsService } from './services/scrap-item-config.service';
|
||||
import { MailsModule } from '../mails/mails.module';
|
||||
import { BidsModule } from '../bids/bids.module';
|
||||
import { ClientScrapConfigsController } from './controllers/client/scrap-configs.controller';
|
||||
import { ClientScrapItemsController } from './controllers/client/scrap-items.controller';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
|
|
@ -17,6 +19,10 @@ import { BidsModule } from '../bids/bids.module';
|
|||
],
|
||||
providers: [ScrapConfigsService, TasksService, ScrapItemsService],
|
||||
exports: [ScrapConfigsService, TasksService, ScrapItemsService],
|
||||
controllers: [ScrapConfigsController],
|
||||
controllers: [
|
||||
ScrapConfigsController,
|
||||
ClientScrapConfigsController,
|
||||
ClientScrapItemsController,
|
||||
],
|
||||
})
|
||||
export class ScrapsModule {}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,11 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { ScrapConfig } from '../entities/scrap-config.entity';
|
||||
import AppResponse from '@/response/app-response';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { plainToClass } from 'class-transformer';
|
||||
import { IsNull, Not, Repository } from 'typeorm';
|
||||
import { CreateScrapConfigDto } from '../dto/scrap-config/create-scrap-config';
|
||||
import { UpdateScrapConfigDto } from '../dto/scrap-config/update-scrap-config';
|
||||
import axios from 'axios';
|
||||
import { WebBid } from '@/modules/bids/entities/wed-bid.entity';
|
||||
import { GraysScrapModel } from '../models/www.grays.com/grays-scrap-model';
|
||||
import { LangtonsScrapModel } from '../models/www.langtons.com.au/langtons-scrap-model';
|
||||
import { ScrapConfig } from '../entities/scrap-config.entity';
|
||||
|
||||
@Injectable()
|
||||
export class ScrapConfigsService {
|
||||
|
|
@ -18,6 +14,17 @@ export class ScrapConfigsService {
|
|||
readonly scrapConfigRepo: Repository<ScrapConfig>,
|
||||
) {}
|
||||
|
||||
async clientGetScrapeConfigs() {
|
||||
const data = await this.scrapConfigRepo.find({
|
||||
where: { search_url: Not(IsNull()), keywords: Not(IsNull()) },
|
||||
relations: {
|
||||
web_bid: true,
|
||||
},
|
||||
});
|
||||
|
||||
return AppResponse.toResponse(plainToClass(ScrapConfig, data));
|
||||
}
|
||||
|
||||
async create(data: CreateScrapConfigDto) {
|
||||
const result = await this.scrapConfigRepo.save({
|
||||
search_url: data.search_url,
|
||||
|
|
@ -40,22 +47,4 @@ export class ScrapConfigsService {
|
|||
|
||||
return AppResponse.toResponse(true);
|
||||
}
|
||||
|
||||
scrapModel(scrapConfig: ScrapConfig) {
|
||||
switch (scrapConfig.web_bid.origin_url) {
|
||||
case 'https://www.grays.com': {
|
||||
return new GraysScrapModel({
|
||||
...scrapConfig,
|
||||
scrap_config_id: scrapConfig.id,
|
||||
});
|
||||
}
|
||||
default: {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
scrapModels(data: ScrapConfig[]) {
|
||||
return data.map((item) => this.scrapModel(item)).filter((item) => !!item);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { ScrapItem } from '../entities/scrap-item.entity';
|
||||
import AppResponse from '@/response/app-response';
|
||||
|
||||
@Injectable()
|
||||
export class ScrapItemsService {
|
||||
|
|
@ -58,4 +59,12 @@ export class ScrapItemsService {
|
|||
updated: toUpdate.length,
|
||||
};
|
||||
}
|
||||
|
||||
async upsertScrapItemsRes(items: ScrapItem[]) {
|
||||
const rs = await this.upsertScrapItems(items);
|
||||
|
||||
if (!rs) throw new BadRequestException(AppResponse.toResponse(null));
|
||||
|
||||
return AppResponse.toResponse(rs);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
import { ConfigsService } from '@/modules/bids/services/configs.service';
|
||||
import { DashboardService } from '@/modules/bids/services/dashboard.service';
|
||||
import { MailsService } from '@/modules/mails/services/mails.service';
|
||||
import { delay } from '@/ultils';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { Between, IsNull, Not } from 'typeorm';
|
||||
import { Cron } from '@nestjs/schedule';
|
||||
import * as moment from 'moment';
|
||||
import { Between } from 'typeorm';
|
||||
import { ScrapConfigsService } from './scrap-config.service';
|
||||
import { ScrapItemsService } from './scrap-item-config.service';
|
||||
import { MailsService } from '@/modules/mails/services/mails.service';
|
||||
import { ConfigsService } from '@/modules/bids/services/configs.service';
|
||||
import * as moment from 'moment';
|
||||
|
||||
@Injectable()
|
||||
export class TasksService {
|
||||
|
|
@ -16,57 +18,79 @@ export class TasksService {
|
|||
private readonly scrapItemsService: ScrapItemsService,
|
||||
private readonly mailsService: MailsService,
|
||||
private readonly configsSerivce: ConfigsService,
|
||||
private readonly dashboardService: DashboardService,
|
||||
) {}
|
||||
|
||||
@Cron('0 2 * * *')
|
||||
async handleScraps() {
|
||||
async runProcessAndSendReport(processName: string) {
|
||||
const mails = (await this.configsSerivce.getConfig('MAIL_SCRAP_REPORT'))
|
||||
.value;
|
||||
?.value;
|
||||
if (!mails) {
|
||||
console.warn('No mails configured for report. Skipping.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!mails) return;
|
||||
// Nếu process đang chạy, không chạy lại
|
||||
const initialStatus =
|
||||
await this.dashboardService.getStatusProcessByName(processName);
|
||||
if (initialStatus === 'online') {
|
||||
console.log(
|
||||
`Process ${processName} is already running. Skipping execution.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const scrapConfigs = await this.scrapConfigsService.scrapConfigRepo.find({
|
||||
where: {
|
||||
search_url: Not(IsNull()),
|
||||
keywords: Not(IsNull()),
|
||||
},
|
||||
relations: {
|
||||
web_bid: true,
|
||||
},
|
||||
});
|
||||
const models = this.scrapConfigsService.scrapModels(scrapConfigs);
|
||||
await Promise.allSettled(
|
||||
models.map(async (item) => {
|
||||
await item.action();
|
||||
for (const key of Object.keys(item.results)) {
|
||||
const dataArray = item.results[key];
|
||||
const result =
|
||||
await this.scrapItemsService.upsertScrapItems(dataArray);
|
||||
console.log(result);
|
||||
}
|
||||
}),
|
||||
);
|
||||
// Reset và chạy process
|
||||
await this.dashboardService.resetProcessByName(processName);
|
||||
console.log(`Process ${processName} started.`);
|
||||
|
||||
// Đợi process kết thúc, có timeout
|
||||
const maxAttempts = 60; // 10 phút
|
||||
let attempts = 0;
|
||||
let status = 'online';
|
||||
|
||||
while (status === 'online' && attempts < maxAttempts) {
|
||||
await delay(10000); // 10 giây
|
||||
status = await this.dashboardService.getStatusProcessByName(processName);
|
||||
attempts++;
|
||||
}
|
||||
|
||||
if (status === 'online') {
|
||||
console.warn(
|
||||
`Process ${processName} still running after timeout. Skipping report.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Khi process kết thúc => gửi mail
|
||||
const startOfDay = new Date();
|
||||
startOfDay.setHours(0, 0, 0, 0);
|
||||
|
||||
const endOfDay = new Date();
|
||||
endOfDay.setHours(23, 59, 59, 999);
|
||||
|
||||
const data = await this.scrapItemsService.scrapItemRepo.find({
|
||||
where: {
|
||||
updated_at: Between(startOfDay, endOfDay),
|
||||
},
|
||||
relations: { scrap_config: { web_bid: true } },
|
||||
order: { updated_at: 'DESC' },
|
||||
});
|
||||
try {
|
||||
const data = await this.scrapItemsService.scrapItemRepo.find({
|
||||
where: {
|
||||
updated_at: Between(startOfDay, endOfDay),
|
||||
},
|
||||
relations: { scrap_config: { web_bid: true } },
|
||||
order: { updated_at: 'DESC' },
|
||||
});
|
||||
|
||||
await this.mailsService.sendPlainHtml(
|
||||
mails,
|
||||
`Auction Items Matching Your Keywords – Daily Update ${moment(new Date()).format('YYYY-MM-DD HH:mm')}`,
|
||||
this.mailsService.generateProductTableHTML(data),
|
||||
);
|
||||
await this.mailsService.sendHtmlMailJob({
|
||||
to: mails,
|
||||
subject: `Auction Items Matching Your Keywords – Daily Update ${moment().format('YYYY-MM-DD HH:mm')}`,
|
||||
html: this.mailsService.generateProductTableHTML(data),
|
||||
});
|
||||
|
||||
console.log('Send report success');
|
||||
console.log('Report mail sent successfully.');
|
||||
} catch (err) {
|
||||
console.error('Failed to generate or send report:', err);
|
||||
}
|
||||
}
|
||||
|
||||
@Cron('0 2 * * *')
|
||||
async handleScraps() {
|
||||
const processName = 'scrape-data-keyword';
|
||||
await this.runProcessAndSendReport(processName);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { Bid } from '@/modules/bids/entities/bid.entity';
|
||||
import * as moment from 'moment';
|
||||
|
||||
export function extractModelId(url: string): string | null {
|
||||
switch (extractDomain(url)) {
|
||||
|
|
@ -31,6 +32,25 @@ export function subtractMinutes(timeStr: string, minutes: number) {
|
|||
return date.toISOString(); // Trả về dạng chuẩn ISO
|
||||
}
|
||||
|
||||
export function extractDomainSmart(url: string) {
|
||||
const PUBLIC_SUFFIXES = ['com.au', 'co.uk', 'com.vn', 'org.au', 'gov.uk'];
|
||||
|
||||
try {
|
||||
const hostname = new URL(url).hostname.replace(/^www\./, ''); // remove "www."
|
||||
const parts = hostname.split('.');
|
||||
|
||||
for (let i = 0; i < PUBLIC_SUFFIXES.length; i++) {
|
||||
if (hostname.endsWith(PUBLIC_SUFFIXES[i])) {
|
||||
return parts[parts.length - PUBLIC_SUFFIXES[i].split('.').length - 1];
|
||||
}
|
||||
}
|
||||
|
||||
return parts[parts.length - 2];
|
||||
} catch (e) {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
export function subtractSeconds(time: string, seconds: number) {
|
||||
const date = new Date(time);
|
||||
date.setSeconds(date.getSeconds() - seconds);
|
||||
|
|
@ -185,3 +205,13 @@ export function extractNumber(str: string) {
|
|||
const match = str.match(/\d+(\.\d+)?/);
|
||||
return match ? parseFloat(match[0]) : null;
|
||||
}
|
||||
|
||||
export function formatEndTime(
|
||||
closeTime: string | Date,
|
||||
extended: boolean,
|
||||
): string {
|
||||
return `${moment(closeTime).format('YYYY-MM-DD HH:mm')} (${extended ? 'extended' : 'no extension'})`;
|
||||
}
|
||||
|
||||
export const delay = (ms: number) =>
|
||||
new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
|
|
|||
|
|
@ -0,0 +1,57 @@
|
|||
# compiled output
|
||||
/dist
|
||||
/node_modules
|
||||
/build
|
||||
/public
|
||||
/system/error-images
|
||||
/system/profiles
|
||||
/system/local-data
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
pnpm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
|
||||
# Tests
|
||||
/coverage
|
||||
/.nyc_output
|
||||
|
||||
# IDEs and editors
|
||||
/.idea
|
||||
.project
|
||||
.classpath
|
||||
.c9/
|
||||
*.launch
|
||||
.settings/
|
||||
*.sublime-workspace
|
||||
|
||||
# IDE - VSCode
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# temp directory
|
||||
.temp
|
||||
.tmp
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
import axios from "../system/axios.js";
|
||||
|
||||
export const getScapeConfigs = async () => {
|
||||
try {
|
||||
const { data } = await axios({
|
||||
method: "GET",
|
||||
url: "scrap-configs",
|
||||
});
|
||||
|
||||
if (!data || !data?.data) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return data.data;
|
||||
} catch (error) {
|
||||
console.log("❌ ERROR IN SERVER (getScapeConfigs): ", error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export const upsertScapeItems = async (values) => {
|
||||
try {
|
||||
const { data } = await axios({
|
||||
method: "POST",
|
||||
url: "scrap-items/upsert",
|
||||
data: values,
|
||||
});
|
||||
|
||||
if (!data || !data?.data) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return data.data;
|
||||
} catch (error) {
|
||||
console.log("❌ ERROR IN SERVER (upsertScapeItems): ", error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
module.exports = {
|
||||
apps: [
|
||||
{
|
||||
name: "scrape-data-keyword",
|
||||
script: "./index.js",
|
||||
instances: 1,
|
||||
exec_mode: "fork",
|
||||
watch: false,
|
||||
log_date_format: "YYYY-MM-DD HH:mm:ss",
|
||||
output: "./logs/out.log",
|
||||
error: "./logs/error.log",
|
||||
merge_logs: true,
|
||||
max_memory_restart: "1G",
|
||||
autorestart: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
import dotenv from "dotenv";
|
||||
import "dotenv/config";
|
||||
dotenv.config();
|
||||
import { getScapeConfigs, upsertScapeItems } from "./apis/scrape.js";
|
||||
import { ScrapConfigsService } from "./services/scrap-configs-service.js";
|
||||
import browser from "./system/browser.js";
|
||||
|
||||
const init = async () => {
|
||||
const scrapConfigs = await getScapeConfigs();
|
||||
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
|
||||
await page.setUserAgent(
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36"
|
||||
);
|
||||
|
||||
const models = ScrapConfigsService.scrapModels(scrapConfigs, page);
|
||||
console.log(`Loaded ${models.length} scrape models`);
|
||||
|
||||
for (let model of models) {
|
||||
try {
|
||||
await model.action();
|
||||
|
||||
for (const key of Object.keys(model.results)) {
|
||||
const dataArray = model.results[key];
|
||||
const result = await upsertScapeItems(dataArray);
|
||||
console.log(`Upserted ${dataArray.length} items for ${key}:`, result);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`Error in model ${model.config?.name || "unknown"}:`,
|
||||
err
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await page.close();
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
};
|
||||
|
||||
init();
|
||||
|
|
@ -0,0 +1,119 @@
|
|||
import browser from "../system/browser.js";
|
||||
import { extractModelId, extractNumber } from "../system/ultils.js";
|
||||
import { ScrapModel } from "./scrap-model.js";
|
||||
|
||||
export class AllbidsScrapModel extends ScrapModel {
|
||||
action = async () => {
|
||||
const urlsData = this.extractUrls();
|
||||
|
||||
for (let item of urlsData) {
|
||||
await this.page.goto(item.url);
|
||||
|
||||
const data = await this.getItemsInHtml(item);
|
||||
|
||||
const results = this.filterItemByKeyword(item.keyword, data);
|
||||
|
||||
this.results[item.keyword] = results;
|
||||
|
||||
console.log({ results: this.results });
|
||||
}
|
||||
};
|
||||
|
||||
async getPrice(url) {
|
||||
const newPage = await browser.newPage(); // cần truyền 'url' từ bên ngoài nếu chưa có
|
||||
|
||||
try {
|
||||
await newPage.goto(`${this.web_bid.origin_url}${url}`, {
|
||||
waitUntil: "domcontentloaded", // hoặc "networkidle2" nếu cần đợi AJAX
|
||||
});
|
||||
|
||||
await newPage.waitForSelector("#bidPrefix > span.font-weight-bold", {
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
const priceText = await newPage
|
||||
.$eval("#bidPrefix > span.font-weight-bold", (el) =>
|
||||
el.textContent.trim()
|
||||
)
|
||||
.catch(() => null);
|
||||
|
||||
return extractNumber(priceText) || 0;
|
||||
} catch (error) {
|
||||
console.error(`Error getting price for ${url}:`, error);
|
||||
return 0;
|
||||
} finally {
|
||||
await newPage.close();
|
||||
}
|
||||
}
|
||||
|
||||
getPriceByEl = async (elementHandle, model) => {
|
||||
try {
|
||||
const priceText = await elementHandle
|
||||
.$eval(`#ps-bg-buy-btn-${model} .pds-button-label`, (el) =>
|
||||
el.textContent.trim()
|
||||
)
|
||||
.catch(() => null);
|
||||
|
||||
return extractNumber(priceText) || 0;
|
||||
} catch (error) {
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
getItemsInHtml = async (data) => {
|
||||
await this.page.waitForSelector('input[name="searchText"]', {
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
await this.page.type('input[name="searchText"]', data.keyword);
|
||||
|
||||
await Promise.all([
|
||||
this.page.click(
|
||||
"form .btn.btn-lg.btn-primary.waves-effect.allbids-cta-bid"
|
||||
),
|
||||
this.page.waitForNavigation({ waitUntil: "networkidle0" }), // hoặc 'networkidle2'
|
||||
]);
|
||||
|
||||
const elements = await this.page.$$("tbody > tr.row.ng-scope");
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const el of elements) {
|
||||
const url = await el
|
||||
.$eval(".col-md-5.col-lg-7.title > a", (el) => el.getAttribute("href"))
|
||||
.catch(() => null);
|
||||
|
||||
const model = extractModelId(url);
|
||||
|
||||
const image_url = await el
|
||||
.$eval(`#list${model} > div > img`, (img) => img.getAttribute("src"))
|
||||
.catch(() => null);
|
||||
|
||||
const name = await el
|
||||
.$eval(`#list${model} > div:nth-child(1) > h3`, (el) =>
|
||||
el.textContent.trim()
|
||||
)
|
||||
.catch(() => null);
|
||||
|
||||
const priceText = await el
|
||||
.$eval(
|
||||
`#list${model} > div:nth-child(1) > div:nth-child(1) > span`,
|
||||
(el) => el.textContent.trim()
|
||||
)
|
||||
.catch(() => null);
|
||||
|
||||
results.push({
|
||||
url,
|
||||
image_url,
|
||||
name,
|
||||
keyword: data.keyword,
|
||||
model,
|
||||
current_price: extractNumber(priceText) || 0,
|
||||
scrap_config_id: this.scrap_config_id,
|
||||
});
|
||||
}
|
||||
|
||||
console.log({ results });
|
||||
return results;
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
import { extractModelId, extractNumber } from "../system/ultils.js";
|
||||
import { ScrapModel } from "./scrap-model.js";
|
||||
|
||||
export class GraysScrapModel extends ScrapModel {
|
||||
action = async () => {
|
||||
const urlsData = this.extractUrls();
|
||||
|
||||
for (let item of urlsData) {
|
||||
await this.page.goto(item.url);
|
||||
|
||||
const data = await this.getItemsInHtml(item);
|
||||
|
||||
const results = this.filterItemByKeyword(item.keyword, data);
|
||||
|
||||
this.results[item.keyword] = results;
|
||||
}
|
||||
|
||||
console.log({ results: this.results });
|
||||
};
|
||||
|
||||
getPriceByEl = async (elementHandle) => {
|
||||
const selectors = [
|
||||
".sc-ijDOKB.sc-bStcSt.ikmQUw.eEycyP", // Single product price
|
||||
".sc-ijDOKB.ikmQUw", // Multiple product price
|
||||
];
|
||||
|
||||
for (const selector of selectors) {
|
||||
const priceText = await elementHandle
|
||||
.$eval(selector, (el) => el.textContent.trim())
|
||||
.catch(() => null);
|
||||
if (priceText) {
|
||||
const price = extractNumber(priceText);
|
||||
if (price) return price;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
getItemsInHtml = async (data) => {
|
||||
const elements = await this.page.$$(".sc-102aeaf3-1.eYPitT > div");
|
||||
const results = [];
|
||||
|
||||
for (const el of elements) {
|
||||
const url = await el
|
||||
.$eval(".sc-pKqro.sc-gFnajm.gqkMpZ.dzWUkJ", (el) =>
|
||||
el.getAttribute("href")
|
||||
)
|
||||
.catch(() => null);
|
||||
|
||||
const image_url = await el
|
||||
.$eval("img.sc-gtJxfw.jbgdlx", (img) => img.getAttribute("src"))
|
||||
.catch(() => null);
|
||||
|
||||
const name = await el
|
||||
.$eval(".sc-jlGgGc.dJRywx", (el) => el.textContent.trim())
|
||||
.catch(() => null);
|
||||
|
||||
const current_price = await this.getPriceByEl(el); // Gọi hàm async được định nghĩa trong class
|
||||
|
||||
results.push({
|
||||
url,
|
||||
image_url,
|
||||
name,
|
||||
keyword: data.keyword,
|
||||
model: extractModelId(url),
|
||||
current_price,
|
||||
scrap_config_id: this.scrap_config_id,
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
import { extractModelId, extractNumber } from "../system/ultils.js";
|
||||
import { ScrapModel } from "./scrap-model.js";
|
||||
|
||||
export class LangtonsScrapModel extends ScrapModel {
|
||||
action = async () => {
|
||||
const urlsData = this.extractUrls();
|
||||
|
||||
for (let item of urlsData) {
|
||||
await this.page.goto(item.url);
|
||||
|
||||
const data = await this.getItemsInHtml(item);
|
||||
|
||||
const results = this.filterItemByKeyword(item.keyword, data);
|
||||
|
||||
this.results[item.keyword] = results;
|
||||
|
||||
console.log({ results: this.results });
|
||||
}
|
||||
};
|
||||
|
||||
getItemsInHtml = async (data) => {
|
||||
const elements = await this.page.$$(".row.product-grid.grid-view");
|
||||
const results = [];
|
||||
|
||||
for (const el of elements) {
|
||||
const url = await el
|
||||
.$eval("a.js-pdp-link.pdp-link-anchor", (el) => el.getAttribute("href"))
|
||||
.catch(() => null);
|
||||
|
||||
const image_url = await el
|
||||
.$eval("img.tile-image.loaded", (img) => img.getAttribute("src"))
|
||||
.catch(() => null);
|
||||
|
||||
const name = await el
|
||||
.$eval(".link.js-pdp-link", (el) => el.textContent.trim())
|
||||
.catch(() => null);
|
||||
|
||||
const current_price = await el
|
||||
.$eval("div.max-bid-price.price", (el) => el.textContent.trim())
|
||||
.catch(() => null);
|
||||
|
||||
results.push({
|
||||
url: `${this.web_bid.origin_url}${url}`,
|
||||
image_url,
|
||||
name,
|
||||
keyword: data.keyword,
|
||||
model: extractModelId(`${this.web_bid.origin_url}${url}`),
|
||||
current_price: extractNumber(current_price),
|
||||
scrap_config_id: this.scrap_config_id,
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
import browser from "../system/browser.js";
|
||||
import { extractModelId, extractNumber } from "../system/ultils.js";
|
||||
import { ScrapModel } from "./scrap-model.js";
|
||||
|
||||
export class LawsonsScrapModel extends ScrapModel {
|
||||
action = async () => {
|
||||
const urlsData = this.extractUrls();
|
||||
|
||||
for (let item of urlsData) {
|
||||
await this.page.goto(item.url);
|
||||
|
||||
const data = await this.getItemsInHtml(item);
|
||||
|
||||
const results = this.filterItemByKeyword(item.keyword, data);
|
||||
|
||||
this.results[item.keyword] = results;
|
||||
|
||||
console.log({ results: this.results });
|
||||
}
|
||||
};
|
||||
|
||||
async getPrice(url) {
|
||||
const newPage = await browser.newPage();
|
||||
|
||||
await newPage.setUserAgent(
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36"
|
||||
);
|
||||
|
||||
try {
|
||||
await newPage.goto(`${this.web_bid.origin_url}${url}`, {
|
||||
waitUntil: "domcontentloaded", // hoặc "networkidle2" nếu cần đợi AJAX
|
||||
});
|
||||
|
||||
await newPage.waitForSelector("#bidPrefix > span.font-weight-bold", {
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
const priceText = await newPage
|
||||
.$eval("#bidPrefix > span.font-weight-bold", (el) =>
|
||||
el.textContent.trim()
|
||||
)
|
||||
.catch(() => null);
|
||||
|
||||
return extractNumber(priceText) || 0;
|
||||
} catch (error) {
|
||||
console.error(`Error getting price for ${url}:`, error);
|
||||
return 0;
|
||||
} finally {
|
||||
await newPage.close();
|
||||
}
|
||||
}
|
||||
|
||||
getItemsInHtml = async (data) => {
|
||||
await this.page.waitForSelector(".row.row-spacing > .lot-container", {
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
const elements = await this.page.$$(".row.row-spacing > .lot-container");
|
||||
const results = [];
|
||||
|
||||
for (const el of elements) {
|
||||
const url = await el
|
||||
.$eval("aside.search-lot--content.text-left > a", (el) =>
|
||||
el.getAttribute("href")
|
||||
)
|
||||
.catch(() => null);
|
||||
|
||||
const image_url = await el
|
||||
.$eval("figure.text-center.imgContainer img", (img) =>
|
||||
img.getAttribute("src")
|
||||
)
|
||||
.catch(() => null);
|
||||
|
||||
const name = await el
|
||||
.$eval(".font-weight-normal.text-grey.title", (el) =>
|
||||
el.textContent.trim()
|
||||
)
|
||||
.catch(() => null);
|
||||
|
||||
const current_price = await this.getPrice(url);
|
||||
|
||||
results.push({
|
||||
url: `${this.web_bid.origin_url}${url}`,
|
||||
image_url,
|
||||
name,
|
||||
keyword: data.keyword,
|
||||
model: extractModelId(`${this.web_bid.origin_url}${url}`),
|
||||
current_price: current_price,
|
||||
scrap_config_id: this.scrap_config_id,
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
import browser from "../system/browser.js";
|
||||
import { extractModelId, extractNumber } from "../system/ultils.js";
|
||||
import { ScrapModel } from "./scrap-model.js";
|
||||
|
||||
export class PicklesScrapModel extends ScrapModel {
|
||||
action = async () => {
|
||||
const urlsData = this.extractUrls();
|
||||
|
||||
for (let item of urlsData) {
|
||||
await this.page.goto(item.url);
|
||||
|
||||
const data = await this.getItemsInHtml(item);
|
||||
|
||||
const results = this.filterItemByKeyword(item.keyword, data);
|
||||
|
||||
this.results[item.keyword] = results;
|
||||
|
||||
console.log({ results: this.results });
|
||||
}
|
||||
};
|
||||
|
||||
async getPrice(url) {
|
||||
const newPage = await browser.newPage(); // cần truyền 'url' từ bên ngoài nếu chưa có
|
||||
|
||||
try {
|
||||
await newPage.goto(`${this.web_bid.origin_url}${url}`, {
|
||||
waitUntil: "domcontentloaded", // hoặc "networkidle2" nếu cần đợi AJAX
|
||||
});
|
||||
|
||||
await newPage.waitForSelector("#bidPrefix > span.font-weight-bold", {
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
const priceText = await newPage
|
||||
.$eval("#bidPrefix > span.font-weight-bold", (el) =>
|
||||
el.textContent.trim()
|
||||
)
|
||||
.catch(() => null);
|
||||
|
||||
return extractNumber(priceText) || 0;
|
||||
} catch (error) {
|
||||
console.error(`Error getting price for ${url}:`, error);
|
||||
return 0;
|
||||
} finally {
|
||||
await newPage.close();
|
||||
}
|
||||
}
|
||||
|
||||
getPriceByEl = async (elementHandle, model) => {
|
||||
try {
|
||||
const priceText = await elementHandle
|
||||
.$eval(`#ps-bg-buy-btn-${model} .pds-button-label`, (el) =>
|
||||
el.textContent.trim()
|
||||
)
|
||||
.catch(() => null);
|
||||
|
||||
return extractNumber(priceText) || 0;
|
||||
} catch (error) {
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
getItemsInHtml = async (data) => {
|
||||
await this.page.waitForSelector(
|
||||
".content-wrapper_contentgridwrapper__3RCQZ > div.column",
|
||||
{
|
||||
timeout: 10000,
|
||||
}
|
||||
);
|
||||
|
||||
const elements = await this.page.$$(
|
||||
".content-wrapper_contentgridwrapper__3RCQZ > div.column"
|
||||
);
|
||||
const results = [];
|
||||
|
||||
for (const el of elements) {
|
||||
const url = await el
|
||||
.$eval("main > a", (el) => el.getAttribute("href"))
|
||||
.catch(() => null);
|
||||
|
||||
const image_url = await el
|
||||
.$eval("div > div:first-child > div > div:first-child > img", (img) =>
|
||||
img.getAttribute("src")
|
||||
)
|
||||
.catch(() => null);
|
||||
|
||||
const name = await el
|
||||
.$eval("header > h2:first-of-type", (el) => el.textContent.trim())
|
||||
.catch(() => null);
|
||||
|
||||
const model = extractModelId(`${this.web_bid.origin_url}${url}`);
|
||||
|
||||
const current_price = await this.getPriceByEl(el, model);
|
||||
|
||||
results.push({
|
||||
url: `${this.web_bid.origin_url}${url}`,
|
||||
image_url,
|
||||
name,
|
||||
keyword: data.keyword,
|
||||
model,
|
||||
current_price: current_price,
|
||||
scrap_config_id: this.scrap_config_id,
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
export class ScrapModel {
|
||||
constructor({ keywords, search_url, scrap_config_id, page, web_bid }) {
|
||||
this.keywords = keywords;
|
||||
this.search_url = search_url;
|
||||
this.scrap_config_id = scrap_config_id;
|
||||
this.results = {};
|
||||
this.page = page;
|
||||
this.web_bid = web_bid;
|
||||
}
|
||||
|
||||
buildUrlWithKey(rawUrl, keyword) {
|
||||
return rawUrl.replaceAll("{{keyword}}", keyword);
|
||||
}
|
||||
|
||||
extractUrls() {
|
||||
const keywordList = this.keywords.split(", ");
|
||||
if (keywordList.length <= 0) return [];
|
||||
|
||||
return keywordList.map((keyword) => ({
|
||||
url: this.buildUrlWithKey(this.search_url, keyword),
|
||||
keyword,
|
||||
}));
|
||||
}
|
||||
|
||||
filterItemByKeyword(keyword, data) {
|
||||
return data.filter((item) =>
|
||||
item.name.toLowerCase().includes(keyword.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
// Phần này bạn cần truyền từ bên ngoài khi khởi tạo hoặc kế thừa class:
|
||||
action = async (page) => {};
|
||||
getInfoItems = (data) => [];
|
||||
getItemsInHtml = async (data) => [];
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"name": "scrape-data-keyword",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"start": "node index.js"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"axios": "^1.8.2",
|
||||
"dotenv": "^16.4.7",
|
||||
"lodash": "^4.17.21",
|
||||
"puppeteer": "^24.4.0",
|
||||
"puppeteer-extra": "^3.3.6",
|
||||
"puppeteer-extra-plugin-stealth": "^2.11.2"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
import { AllbidsScrapModel } from "../models/allbids-scrap-model.js";
|
||||
import { GraysScrapModel } from "../models/grays-scrap-model.js";
|
||||
import { LangtonsScrapModel } from "../models/langtons-scrap-model.js";
|
||||
import { LawsonsScrapModel } from "../models/lawsons-scrap-model.js";
|
||||
import { PicklesScrapModel } from "../models/pickles-scrap-model.js";
|
||||
|
||||
export class ScrapConfigsService {
|
||||
static scrapModel(scrapConfig, page) {
|
||||
switch (scrapConfig.web_bid.origin_url) {
|
||||
case "https://www.grays.com": {
|
||||
return new GraysScrapModel({
|
||||
...scrapConfig,
|
||||
scrap_config_id: scrapConfig.id,
|
||||
page: page,
|
||||
});
|
||||
}
|
||||
case "https://www.langtons.com.au": {
|
||||
return new LangtonsScrapModel({
|
||||
...scrapConfig,
|
||||
scrap_config_id: scrapConfig.id,
|
||||
page: page,
|
||||
});
|
||||
}
|
||||
case "https://www.lawsons.com.au": {
|
||||
return new LawsonsScrapModel({
|
||||
...scrapConfig,
|
||||
scrap_config_id: scrapConfig.id,
|
||||
page: page,
|
||||
});
|
||||
}
|
||||
case "https://www.pickles.com.au": {
|
||||
return new PicklesScrapModel({
|
||||
...scrapConfig,
|
||||
scrap_config_id: scrapConfig.id,
|
||||
page: page,
|
||||
});
|
||||
}
|
||||
case "https://www.allbids.com.au": {
|
||||
return new AllbidsScrapModel({
|
||||
...scrapConfig,
|
||||
scrap_config_id: scrapConfig.id,
|
||||
page: page,
|
||||
});
|
||||
}
|
||||
default: {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static scrapModels(data, page) {
|
||||
return data
|
||||
.map((item) => this.scrapModel(item, page))
|
||||
.filter((item) => !!item);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
import ax from "axios";
|
||||
|
||||
const axios = ax.create({
|
||||
// baseURL: 'http://172.18.2.125/api/v1/',
|
||||
baseURL: process.env.BASE_URL,
|
||||
headers: {
|
||||
Authorization: process.env.CLIENT_KEY,
|
||||
},
|
||||
});
|
||||
|
||||
export default axios;
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
// import puppeteer from 'puppeteer';
|
||||
import puppeteer from "puppeteer-extra";
|
||||
import StealthPlugin from "puppeteer-extra-plugin-stealth";
|
||||
|
||||
puppeteer.use(StealthPlugin());
|
||||
const browser = await puppeteer.launch({
|
||||
headless: process.env.ENVIRONMENT === "prod" ? "new" : false,
|
||||
// userDataDir: CONSTANTS.PROFILE_PATH, // Thư mục lưu profile
|
||||
timeout: 60000,
|
||||
args: [
|
||||
"--no-sandbox",
|
||||
"--disable-setuid-sandbox",
|
||||
"--disable-dev-shm-usage",
|
||||
"--disable-gpu",
|
||||
"--disable-software-rasterizer",
|
||||
"--disable-background-networking",
|
||||
"--disable-sync",
|
||||
"--mute-audio",
|
||||
"--no-first-run",
|
||||
"--no-default-browser-check",
|
||||
"--ignore-certificate-errors",
|
||||
"--start-maximized",
|
||||
"--disable-site-isolation-trials", // Tắt sandbox riêng cho từng site
|
||||
"--memory-pressure-off", // Tắt cơ chế bảo vệ bộ nhớ
|
||||
"--disk-cache-size=0", // Không dùng cache để giảm bộ nhớ
|
||||
"--enable-low-end-device-mode", // Kích hoạt chế độ tiết kiệm RAM
|
||||
"--disable-best-effort-tasks", // Tắt tác vụ không quan trọng
|
||||
"--disable-accelerated-2d-canvas", // Không dùng GPU để vẽ canvas
|
||||
"--disable-threaded-animation", // Giảm animation chạy trên nhiều thread
|
||||
"--disable-threaded-scrolling", // Tắt cuộn trang đa luồng
|
||||
"--disable-logging", // Tắt log debug
|
||||
"--blink-settings=imagesEnabled=false", // Không tải hình ảnh,
|
||||
"--disable-background-timer-throttling", // Tránh việc throttling các timer khi chạy nền.
|
||||
"--disable-webrtc",
|
||||
"--disable-ipc-flooding-protection", // Nếu có extension cần IPC, cái này giúp tối ưu.
|
||||
],
|
||||
});
|
||||
|
||||
export default browser;
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
export function extractNumber(str) {
|
||||
if (typeof str !== "string") return null;
|
||||
|
||||
const cleaned = str.replace(/,/g, "");
|
||||
const match = cleaned.match(/\d+(\.\d+)?/);
|
||||
return match ? parseFloat(match[0]) : null;
|
||||
}
|
||||
|
||||
export function extractModelId(url) {
|
||||
try {
|
||||
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;
|
||||
}
|
||||
case "https://www.pickles.com.au": {
|
||||
const model = url.split("/").pop();
|
||||
return model ? model : null;
|
||||
}
|
||||
case "https://www.allbids.com.au": {
|
||||
const match = url.match(/-(\d+)(?:[\?#]|$)/);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function extractDomain(url) {
|
||||
try {
|
||||
const parsedUrl = new URL(url);
|
||||
return parsedUrl.origin;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue