Merge pull request 'Deploy to production' (#56) from staging into production
Reviewed-on: #56
This commit is contained in:
commit
e9f9a339c1
|
|
@ -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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -16,7 +16,10 @@ export const createScrapConfig = async (
|
||||||
url: "scrap-configs",
|
url: "scrap-configs",
|
||||||
withCredentials: true,
|
withCredentials: true,
|
||||||
method: "POST",
|
method: "POST",
|
||||||
data: newData,
|
data: {
|
||||||
|
...newData,
|
||||||
|
enable: newData.enable === "1",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
handleSuccess(data);
|
handleSuccess(data);
|
||||||
|
|
@ -28,14 +31,14 @@ export const createScrapConfig = async (
|
||||||
};
|
};
|
||||||
|
|
||||||
export const updateScrapConfig = async (scrapConfig: Partial<IScrapConfig>) => {
|
export const updateScrapConfig = async (scrapConfig: Partial<IScrapConfig>) => {
|
||||||
const { search_url, keywords, id } = removeFalsyValues(scrapConfig);
|
const { search_url, keywords, id, enable } = removeFalsyValues(scrapConfig);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { data } = await axios({
|
const { data } = await axios({
|
||||||
url: "scrap-configs/" + id,
|
url: "scrap-configs/" + id,
|
||||||
withCredentials: true,
|
withCredentials: true,
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
data: { search_url, keywords },
|
data: { search_url, keywords, enable: enable === "1" },
|
||||||
});
|
});
|
||||||
|
|
||||||
handleSuccess(data);
|
handleSuccess(data);
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
import { useForm, zodResolver } from '@mantine/form';
|
Avatar,
|
||||||
import { useDisclosure } from '@mantine/hooks';
|
Button,
|
||||||
import { IconKey, IconLogout, IconSettings, IconUser } from '@tabler/icons-react';
|
LoadingOverlay,
|
||||||
import { useState } from 'react';
|
Menu,
|
||||||
import { useNavigate } from 'react-router';
|
Modal,
|
||||||
import { Link } from 'react-router-dom';
|
PasswordInput,
|
||||||
import { z } from 'zod';
|
} from "@mantine/core";
|
||||||
import { changePassword, logout } from '../apis/auth';
|
import { useForm, zodResolver } from "@mantine/form";
|
||||||
import { useConfirmStore } from '../lib/zustand/use-confirm';
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
import Links from '../system/links';
|
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
|
const schema = z
|
||||||
.object({
|
.object({
|
||||||
currentPassword: z.string().min(6, 'Current password must be at least 6 characters'),
|
currentPassword: z
|
||||||
newPassword: z.string().min(6, 'New password must be at least 6 characters'),
|
.string()
|
||||||
confirmPassword: z.string(),
|
.min(6, "Current password must be at least 6 characters"),
|
||||||
})
|
newPassword: z
|
||||||
.refine((data) => data.newPassword === data.confirmPassword, {
|
.string()
|
||||||
path: ['confirmPassword'],
|
.min(6, "New password must be at least 6 characters"),
|
||||||
message: 'Passwords do not match',
|
confirmPassword: z.string(),
|
||||||
});
|
})
|
||||||
|
.refine((data) => data.newPassword === data.confirmPassword, {
|
||||||
|
path: ["confirmPassword"],
|
||||||
|
message: "Passwords do not match",
|
||||||
|
});
|
||||||
|
|
||||||
export default function UserMenu() {
|
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 navigate = useNavigate();
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
initialValues: {
|
initialValues: {
|
||||||
currentPassword: '',
|
currentPassword: "",
|
||||||
newPassword: '',
|
newPassword: "",
|
||||||
confirmPassword: '',
|
confirmPassword: "",
|
||||||
},
|
},
|
||||||
validate: zodResolver(schema),
|
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) => {
|
const handleChangePassword = async (values: typeof form.values) => {
|
||||||
await handleChangePassword(values);
|
setConfirm({
|
||||||
};
|
title: "Are you wan't to update password",
|
||||||
|
message: "This account will change password !",
|
||||||
const handleLogout = async () => {
|
okButton: { value: "Sure" },
|
||||||
setConfirm({
|
handleOk: async () => {
|
||||||
title: "Are you wan't to logout?",
|
setLoading(true);
|
||||||
message: 'This account will logout !',
|
const data = await changePassword({
|
||||||
okButton: { value: 'Logout' },
|
newPassword: values.newPassword,
|
||||||
handleOk: async () => {
|
password: values.currentPassword,
|
||||||
const data = await logout();
|
|
||||||
|
|
||||||
if (data && data.data) {
|
|
||||||
navigate(Links.LOGIN);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
};
|
|
||||||
|
|
||||||
const handleChangePassword = async (values: typeof form.values) => {
|
setLoading(false);
|
||||||
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);
|
if (data && data.data) {
|
||||||
|
navigate(Links.LOGIN);
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
if (data && data.data) {
|
return (
|
||||||
navigate(Links.LOGIN);
|
<>
|
||||||
close();
|
<Menu shadow="md" width={200}>
|
||||||
}
|
<Menu.Target>
|
||||||
},
|
<Avatar color="blue" radius="xl" className="cursor-pointer">
|
||||||
});
|
<IconUser size={20} />
|
||||||
};
|
</Avatar>
|
||||||
|
</Menu.Target>
|
||||||
|
|
||||||
return (
|
<Menu.Dropdown>
|
||||||
<>
|
<Menu.Label>Account</Menu.Label>
|
||||||
<Menu shadow="md" width={200}>
|
<Menu.Item onClick={open} leftSection={<IconSettings size={14} />}>
|
||||||
<Menu.Target>
|
Change password
|
||||||
<Avatar color="blue" radius="xl" className="cursor-pointer">
|
</Menu.Item>
|
||||||
<IconUser size={20} />
|
<Menu.Item
|
||||||
</Avatar>
|
component={Link}
|
||||||
</Menu.Target>
|
to={Links.GENERATE_KEYS}
|
||||||
|
leftSection={<IconKey size={14} />}
|
||||||
|
>
|
||||||
|
Keys
|
||||||
|
</Menu.Item>
|
||||||
|
|
||||||
<Menu.Dropdown>
|
<Menu.Item
|
||||||
<Menu.Label>Account</Menu.Label>
|
component={Link}
|
||||||
<Menu.Item onClick={open} leftSection={<IconSettings size={14} />}>
|
to={Links.CONFIGS}
|
||||||
Change password
|
leftSection={<IconCode size={14} />}
|
||||||
</Menu.Item>
|
>
|
||||||
<Menu.Item component={Link} to={Links.GENERATE_KEYS} leftSection={<IconKey size={14} />}>
|
Configs
|
||||||
Keys
|
</Menu.Item>
|
||||||
</Menu.Item>
|
|
||||||
|
|
||||||
<Menu.Divider />
|
<Menu.Divider />
|
||||||
|
|
||||||
<Menu.Item onClick={handleLogout} color="red" leftSection={<IconLogout size={14} />}>
|
<Menu.Item
|
||||||
Logout
|
onClick={handleLogout}
|
||||||
</Menu.Item>
|
color="red"
|
||||||
</Menu.Dropdown>
|
leftSection={<IconLogout size={14} />}
|
||||||
</Menu>
|
>
|
||||||
|
Logout
|
||||||
|
</Menu.Item>
|
||||||
|
</Menu.Dropdown>
|
||||||
|
</Menu>
|
||||||
|
|
||||||
<Modal className="relative" opened={opened} onClose={close} title="Change password" centered>
|
<Modal
|
||||||
<form onSubmit={form.onSubmit(handleSubmit)} className="flex flex-col gap-2.5">
|
className="relative"
|
||||||
<PasswordInput size="sm" label="Current password" {...form.getInputProps('currentPassword')} />
|
opened={opened}
|
||||||
<PasswordInput size="sm" label="New password" {...form.getInputProps('newPassword')} />
|
onClose={close}
|
||||||
<PasswordInput size="sm" label="Confirm password" {...form.getInputProps('confirmPassword')} />
|
title="Change password"
|
||||||
<Button type="submit" fullWidth size="sm" mt="md">
|
centered
|
||||||
Change
|
>
|
||||||
</Button>
|
<form
|
||||||
</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 }} />
|
<LoadingOverlay
|
||||||
</Modal>
|
visible={loading}
|
||||||
</>
|
zIndex={1000}
|
||||||
);
|
overlayProps={{ blur: 2 }}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import {
|
||||||
LoadingOverlay,
|
LoadingOverlay,
|
||||||
Modal,
|
Modal,
|
||||||
ModalProps,
|
ModalProps,
|
||||||
|
Select,
|
||||||
Textarea,
|
Textarea,
|
||||||
TextInput,
|
TextInput,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
|
|
@ -28,6 +29,7 @@ const schema = z.object({
|
||||||
.string({ message: "Keyword is required" })
|
.string({ message: "Keyword is required" })
|
||||||
.min(1, { message: "Keyword is required" })
|
.min(1, { message: "Keyword is required" })
|
||||||
.optional(),
|
.optional(),
|
||||||
|
enable: z.enum(["1", "0"], { required_error: "Enable is required" }),
|
||||||
});
|
});
|
||||||
|
|
||||||
export default function ScrapConfigModal({
|
export default function ScrapConfigModal({
|
||||||
|
|
@ -93,9 +95,18 @@ export default function ScrapConfigModal({
|
||||||
form.reset();
|
form.reset();
|
||||||
if (!data) return;
|
if (!data) return;
|
||||||
|
|
||||||
form.setValues(data.scrap_config);
|
const values = {
|
||||||
|
...data.scrap_config,
|
||||||
|
enable: (data.scrap_config?.enable === undefined
|
||||||
|
? "1"
|
||||||
|
: data.scrap_config.enable
|
||||||
|
? "1"
|
||||||
|
: "0") as "0" | "1",
|
||||||
|
};
|
||||||
|
|
||||||
prevData.current = data.scrap_config;
|
form.setValues(values);
|
||||||
|
|
||||||
|
prevData.current = values;
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
|
|
@ -121,6 +132,23 @@ export default function ScrapConfigModal({
|
||||||
onSubmit={form.onSubmit(handleSubmit)}
|
onSubmit={form.onSubmit(handleSubmit)}
|
||||||
className="grid grid-cols-2 gap-2.5"
|
className="grid grid-cols-2 gap-2.5"
|
||||||
>
|
>
|
||||||
|
<Select
|
||||||
|
className="col-span-2"
|
||||||
|
label="Enable scrape"
|
||||||
|
defaultChecked={true}
|
||||||
|
defaultValue={"1"}
|
||||||
|
data={[
|
||||||
|
{
|
||||||
|
label: "Enbale",
|
||||||
|
value: "1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Disable",
|
||||||
|
value: "0",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
{...form.getInputProps("enable")}
|
||||||
|
/>
|
||||||
<TextInput
|
<TextInput
|
||||||
className="col-span-2"
|
className="col-span-2"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
|
||||||
|
|
@ -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 Dashboard } from "./dashboard";
|
||||||
export { default as Bids } from './bids';
|
export { default as Bids } from "./bids";
|
||||||
export { default as OutBidsLog } from './out-bids-log';
|
export { default as OutBidsLog } from "./out-bids-log";
|
||||||
export { default as Login } from './login';
|
export { default as Login } from "./login";
|
||||||
export { default as App } from './app';
|
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 {
|
||||||
import { Bids, Dashboard, OutBidsLog } from '../pages';
|
IconHammer,
|
||||||
import WebBids from '../pages/web-bids';
|
IconHome2,
|
||||||
import SendMessageHistories from '../pages/send-message-histories';
|
IconKey,
|
||||||
import Admins from '../pages/admins';
|
IconMessage,
|
||||||
import GenerateKeys from '../pages/generate-keys';
|
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 {
|
export default class Links {
|
||||||
public static DASHBOARD = '/dashboard';
|
public static DASHBOARD = "/dashboard";
|
||||||
public static BIDS = '/bids';
|
public static BIDS = "/bids";
|
||||||
public static WEBS = '/webs';
|
public static WEBS = "/webs";
|
||||||
public static OUT_BIDS_LOG = '/out-bids-log';
|
public static OUT_BIDS_LOG = "/out-bids-log";
|
||||||
public static SEND_MESSAGE_HISTORIES = '/send-message-histories';
|
public static SEND_MESSAGE_HISTORIES = "/send-message-histories";
|
||||||
public static GENERATE_KEYS = '/generate-keys';
|
public static GENERATE_KEYS = "/generate-keys";
|
||||||
public static ADMINS = '/admins';
|
public static ADMINS = "/admins";
|
||||||
|
public static CONFIGS = "/configs";
|
||||||
|
|
||||||
public static HOME = '/';
|
public static HOME = "/";
|
||||||
public static LOGIN = '/login';
|
public static LOGIN = "/login";
|
||||||
|
|
||||||
public static MENUS = [
|
public static MENUS = [
|
||||||
{
|
{
|
||||||
path: this.DASHBOARD,
|
path: this.DASHBOARD,
|
||||||
title: 'Dashboard',
|
title: "Dashboard",
|
||||||
icon: IconHome2,
|
icon: IconHome2,
|
||||||
element: Dashboard,
|
element: Dashboard,
|
||||||
show: true,
|
show: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: this.ADMINS,
|
path: this.ADMINS,
|
||||||
title: 'Admins',
|
title: "Admins",
|
||||||
icon: IconUserCheck,
|
icon: IconUserCheck,
|
||||||
element: Admins,
|
element: Admins,
|
||||||
show: true,
|
show: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: this.WEBS,
|
path: this.WEBS,
|
||||||
title: 'Webs',
|
title: "Webs",
|
||||||
icon: IconPageBreak,
|
icon: IconPageBreak,
|
||||||
element: WebBids,
|
element: WebBids,
|
||||||
show: true,
|
show: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: this.BIDS,
|
path: this.BIDS,
|
||||||
title: 'Bids',
|
title: "Bids",
|
||||||
icon: IconHammer,
|
icon: IconHammer,
|
||||||
element: Bids,
|
element: Bids,
|
||||||
show: true,
|
show: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: this.OUT_BIDS_LOG,
|
path: this.OUT_BIDS_LOG,
|
||||||
title: 'Out bids log',
|
title: "Out bids log",
|
||||||
icon: IconOutlet,
|
icon: IconOutlet,
|
||||||
element: OutBidsLog,
|
element: OutBidsLog,
|
||||||
show: true,
|
show: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: this.SEND_MESSAGE_HISTORIES,
|
path: this.SEND_MESSAGE_HISTORIES,
|
||||||
title: 'Send message histories',
|
title: "Send message histories",
|
||||||
icon: IconMessage,
|
icon: IconMessage,
|
||||||
element: SendMessageHistories,
|
element: SendMessageHistories,
|
||||||
show: true,
|
show: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: this.GENERATE_KEYS,
|
path: this.GENERATE_KEYS,
|
||||||
title: 'Generate keys',
|
title: "Generate keys",
|
||||||
icon: IconKey,
|
icon: IconKey,
|
||||||
element: GenerateKeys,
|
element: GenerateKeys,
|
||||||
show: false,
|
show: false,
|
||||||
},
|
},
|
||||||
];
|
{
|
||||||
|
path: this.CONFIGS,
|
||||||
|
title: "Configs",
|
||||||
|
icon: IconSettings,
|
||||||
|
element: Configs,
|
||||||
|
show: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ export interface IScrapConfig extends ITimestamp {
|
||||||
id: number;
|
id: number;
|
||||||
search_url: string;
|
search_url: string;
|
||||||
keywords: string;
|
keywords: string;
|
||||||
|
enable: boolean | "0" | "1";
|
||||||
scrap_items: IScrapItem[];
|
scrap_items: IScrapItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -82,6 +83,13 @@ export interface IBid extends ITimestamp {
|
||||||
web_bid: IWebBid;
|
web_bid: IWebBid;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface IConfig extends ITimestamp {
|
||||||
|
id: number;
|
||||||
|
key_name: string;
|
||||||
|
value: string;
|
||||||
|
type: "string" | "number";
|
||||||
|
}
|
||||||
|
|
||||||
export interface IPermission extends ITimestamp {
|
export interface IPermission extends ITimestamp {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
{"createdAt":1747701959077}
|
{"createdAt":1747812172479}
|
||||||
|
|
@ -10,6 +10,7 @@
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nestjs-modules/mailer": "^2.0.2",
|
"@nestjs-modules/mailer": "^2.0.2",
|
||||||
|
"@nestjs/bull": "^11.0.2",
|
||||||
"@nestjs/common": "^10.0.0",
|
"@nestjs/common": "^10.0.0",
|
||||||
"@nestjs/config": "^4.0.1",
|
"@nestjs/config": "^4.0.1",
|
||||||
"@nestjs/core": "^10.0.0",
|
"@nestjs/core": "^10.0.0",
|
||||||
|
|
@ -24,6 +25,7 @@
|
||||||
"@nestjs/websockets": "^11.0.11",
|
"@nestjs/websockets": "^11.0.11",
|
||||||
"axios": "^1.8.3",
|
"axios": "^1.8.3",
|
||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
|
"bull": "^4.16.5",
|
||||||
"cheerio": "^1.0.0",
|
"cheerio": "^1.0.0",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.1",
|
"class-validator": "^0.14.1",
|
||||||
|
|
@ -31,6 +33,7 @@
|
||||||
"cookie-parser": "^1.4.7",
|
"cookie-parser": "^1.4.7",
|
||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
"imap": "^0.8.19",
|
"imap": "^0.8.19",
|
||||||
|
"ioredis": "^5.6.1",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"moment": "^2.30.1",
|
"moment": "^2.30.1",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^1.4.5-lts.1",
|
||||||
|
|
@ -1649,6 +1652,12 @@
|
||||||
"url": "https://opencollective.com/libvips"
|
"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": {
|
"node_modules/@isaacs/cliui": {
|
||||||
"version": "8.0.2",
|
"version": "8.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
||||||
|
|
@ -2337,6 +2346,84 @@
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true
|
"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": {
|
"node_modules/@nestjs-modules/mailer": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@nestjs-modules/mailer/-/mailer-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@nestjs-modules/mailer/-/mailer-2.0.2.tgz",
|
||||||
|
|
@ -2412,6 +2499,34 @@
|
||||||
"@pkgjs/parseargs": "^0.11.0"
|
"@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": {
|
"node_modules/@nestjs/cli": {
|
||||||
"version": "10.4.9",
|
"version": "10.4.9",
|
||||||
"resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-10.4.9.tgz",
|
"resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-10.4.9.tgz",
|
||||||
|
|
@ -4663,6 +4778,33 @@
|
||||||
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
|
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/busboy": {
|
||||||
"version": "1.6.0",
|
"version": "1.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
|
||||||
|
|
@ -5068,6 +5210,15 @@
|
||||||
"node": ">=0.8"
|
"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": {
|
"node_modules/co": {
|
||||||
"version": "4.6.0",
|
"version": "4.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz",
|
||||||
|
|
@ -5400,6 +5551,18 @@
|
||||||
"node": ">=18.x"
|
"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": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/get-port/-/get-port-5.1.1.tgz",
|
||||||
"integrity": "sha512-g/Q1aTSDOxFpchXC4i8ZWvxA1lnPqx/JHqcpIw0/LX9T8x/GBbi6YnlN5nhaKIFkT8oFsscUKgDJYxfwfS6QsQ==",
|
"integrity": "sha512-g/Q1aTSDOxFpchXC4i8ZWvxA1lnPqx/JHqcpIw0/LX9T8x/GBbi6YnlN5nhaKIFkT8oFsscUKgDJYxfwfS6QsQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
},
|
},
|
||||||
|
|
@ -7904,6 +8066,30 @@
|
||||||
"node": ">=12.0.0"
|
"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": {
|
"node_modules/ipaddr.js": {
|
||||||
"version": "1.9.1",
|
"version": "1.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||||
|
|
@ -9506,12 +9692,24 @@
|
||||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/lodash.includes": {
|
||||||
"version": "4.3.0",
|
"version": "4.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
|
||||||
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
|
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/lodash.isboolean": {
|
||||||
"version": "3.0.3",
|
"version": "3.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
|
||||||
|
|
@ -10421,6 +10619,37 @@
|
||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/multer": {
|
||||||
"version": "1.4.5-lts.1",
|
"version": "1.4.5-lts.1",
|
||||||
"resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.1.tgz",
|
"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": {
|
"node_modules/node-int64": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
|
||||||
|
|
@ -11847,6 +12091,27 @@
|
||||||
"node": ">= 12.13.0"
|
"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": {
|
"node_modules/reflect-metadata": {
|
||||||
"version": "0.2.2",
|
"version": "0.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
|
||||||
|
|
@ -12979,6 +13244,12 @@
|
||||||
"node": ">=8"
|
"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": {
|
"node_modules/statuses": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nestjs-modules/mailer": "^2.0.2",
|
"@nestjs-modules/mailer": "^2.0.2",
|
||||||
|
"@nestjs/bull": "^11.0.2",
|
||||||
"@nestjs/common": "^10.0.0",
|
"@nestjs/common": "^10.0.0",
|
||||||
"@nestjs/config": "^4.0.1",
|
"@nestjs/config": "^4.0.1",
|
||||||
"@nestjs/core": "^10.0.0",
|
"@nestjs/core": "^10.0.0",
|
||||||
|
|
@ -40,6 +41,7 @@
|
||||||
"@nestjs/websockets": "^11.0.11",
|
"@nestjs/websockets": "^11.0.11",
|
||||||
"axios": "^1.8.3",
|
"axios": "^1.8.3",
|
||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
|
"bull": "^4.16.5",
|
||||||
"cheerio": "^1.0.0",
|
"cheerio": "^1.0.0",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.1",
|
"class-validator": "^0.14.1",
|
||||||
|
|
@ -47,6 +49,7 @@
|
||||||
"cookie-parser": "^1.4.7",
|
"cookie-parser": "^1.4.7",
|
||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
"imap": "^0.8.19",
|
"imap": "^0.8.19",
|
||||||
|
"ioredis": "^5.6.1",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"moment": "^2.30.1",
|
"moment": "^2.30.1",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^1.4.5-lts.1",
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
import { Module } from '@nestjs/common';
|
import { BullModule } from '@nestjs/bull';
|
||||||
import { ConfigModule } from '@nestjs/config';
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
import { EventEmitterModule } from '@nestjs/event-emitter';
|
import { EventEmitterModule } from '@nestjs/event-emitter';
|
||||||
import { ScheduleModule } from '@nestjs/schedule';
|
import { ScheduleModule } from '@nestjs/schedule';
|
||||||
|
|
||||||
|
@Global()
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
ConfigModule.forRoot({
|
ConfigModule.forRoot({
|
||||||
|
|
@ -12,7 +14,21 @@ import { ScheduleModule } from '@nestjs/schedule';
|
||||||
wildcard: true,
|
wildcard: true,
|
||||||
global: true,
|
global: true,
|
||||||
}),
|
}),
|
||||||
ScheduleModule.forRoot()
|
BullModule.forRootAsync({
|
||||||
|
imports: [ConfigModule],
|
||||||
|
useFactory: (configService: ConfigService) => ({
|
||||||
|
redis: {
|
||||||
|
host: configService.get<string>('REDIS_HOST'),
|
||||||
|
port: configService.get<number>('REDIS_PORT'),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
inject: [ConfigService],
|
||||||
|
}),
|
||||||
|
BullModule.registerQueue({
|
||||||
|
name: 'mail-queue',
|
||||||
|
}),
|
||||||
|
ScheduleModule.forRoot(),
|
||||||
],
|
],
|
||||||
|
exports: [BullModule],
|
||||||
})
|
})
|
||||||
export class AppConfigsModule {}
|
export class AppConfigsModule {}
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ import { AdminDashboardController } from './controllers/admin/admin-dashboard.co
|
||||||
import { TasksService } from './services/tasks.servise';
|
import { TasksService } from './services/tasks.servise';
|
||||||
import { ConfigsService } from './services/configs.service';
|
import { ConfigsService } from './services/configs.service';
|
||||||
import { Config } from './entities/configs.entity';
|
import { Config } from './entities/configs.entity';
|
||||||
|
import { AdminConfigsController } from './controllers/admin/admin-configs.controller';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
|
@ -55,6 +56,7 @@ import { Config } from './entities/configs.entity';
|
||||||
AdminWebBidsController,
|
AdminWebBidsController,
|
||||||
AdminSendMessageHistoriesController,
|
AdminSendMessageHistoriesController,
|
||||||
AdminDashboardController,
|
AdminDashboardController,
|
||||||
|
AdminConfigsController,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
BidsService,
|
BidsService,
|
||||||
|
|
@ -76,6 +78,7 @@ import { Config } from './entities/configs.entity';
|
||||||
SendMessageHistoriesService,
|
SendMessageHistoriesService,
|
||||||
BidsService,
|
BidsService,
|
||||||
ConfigsService,
|
ConfigsService,
|
||||||
|
DashboardService,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class BidsModule {}
|
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 { NotificationService } from '@/modules/notification/notification.service';
|
||||||
import { isTimeReached } from '@/ultils';
|
import { isTimeReached } from '@/ultils';
|
||||||
import { BidsService } from './bids.service';
|
import { BidsService } from './bids.service';
|
||||||
|
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||||
|
import { Event } from '../utils/events';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BidHistoriesService {
|
export class BidHistoriesService {
|
||||||
|
|
@ -28,6 +30,7 @@ export class BidHistoriesService {
|
||||||
readonly sendMessageHistoriesService: SendMessageHistoriesService,
|
readonly sendMessageHistoriesService: SendMessageHistoriesService,
|
||||||
private readonly notificationService: NotificationService,
|
private readonly notificationService: NotificationService,
|
||||||
private readonly bidsService: BidsService,
|
private readonly bidsService: BidsService,
|
||||||
|
private eventEmitter: EventEmitter2,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async index() {
|
async index() {
|
||||||
|
|
@ -38,6 +41,7 @@ export class BidHistoriesService {
|
||||||
// Tìm thông tin bid từ database
|
// Tìm thông tin bid từ database
|
||||||
const bid = await this.bidsService.bidsRepo.findOne({
|
const bid = await this.bidsService.bidsRepo.findOne({
|
||||||
where: { id: bid_id },
|
where: { id: bid_id },
|
||||||
|
relations: { web_bid: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Nếu không tìm thấy bid, trả về lỗi 404
|
// 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 };
|
const botData = { ...bid, histories: response };
|
||||||
this.botTelegramApi.sendBidInfo(botData);
|
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
|
// Lưu message đã gửi để theo dõi
|
||||||
this.sendMessageHistoriesService.sendMessageRepo.save({
|
this.sendMessageHistoriesService.sendMessageRepo.save({
|
||||||
message: this.botTelegramApi.formatBidMessage(botData),
|
message: this.botTelegramApi.formatBidMessage(botData),
|
||||||
|
|
|
||||||
|
|
@ -153,7 +153,10 @@ export class BidsService {
|
||||||
}
|
}
|
||||||
|
|
||||||
async toggle(id: Bid['id']) {
|
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) {
|
if (!bid) {
|
||||||
throw new NotFoundException(
|
throw new NotFoundException(
|
||||||
|
|
@ -301,7 +304,10 @@ export class BidsService {
|
||||||
async outBid(id: Bid['id']) {
|
async outBid(id: Bid['id']) {
|
||||||
const result = await this.bidsRepo.update(id, { status: 'out-bid' });
|
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));
|
if (!result) throw new BadRequestException(AppResponse.toResponse(false));
|
||||||
|
|
||||||
|
|
@ -353,7 +359,10 @@ export class BidsService {
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateStatusByPrice(id: Bid['id'], data: UpdateStatusByPriceDto) {
|
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)
|
if (!bid)
|
||||||
throw new NotFoundException(
|
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 { EventEmitter2 } from '@nestjs/event-emitter';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { Config } from '../entities/configs.entity';
|
import { Config } from '../entities/configs.entity';
|
||||||
|
import AppResponse from '@/response/app-response';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ConfigsService {
|
export class ConfigsService {
|
||||||
|
|
@ -29,4 +35,41 @@ export class ConfigsService {
|
||||||
'key_name',
|
'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 ADMIN_BIDS_UPDATED = 'adminBidsUpdated';
|
||||||
public static WEB_UPDATED = 'webUpdated';
|
public static WEB_UPDATED = 'webUpdated';
|
||||||
public static LOGIN_STATUS = 'login-status';
|
public static LOGIN_STATUS = 'login-status';
|
||||||
|
public static BID_SUBMITED = 'bid-submited';
|
||||||
|
public static BID_STATUS = 'bid-status';
|
||||||
|
|
||||||
public static verifyCode(data: WebBid) {
|
public static verifyCode(data: WebBid) {
|
||||||
return `${this.VERIFY_CODE}.${data.origin_url}`;
|
return `${this.VERIFY_CODE}.${data.origin_url}`;
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,8 @@ import { MailerModule } from '@nestjs-modules/mailer';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { MailsService } from './services/mails.service';
|
import { MailsService } from './services/mails.service';
|
||||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
|
import { BullModule } from '@nestjs/bull';
|
||||||
|
import { MailProcessor } from './process/mail.processor';
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
MailerModule.forRootAsync({
|
MailerModule.forRootAsync({
|
||||||
|
|
@ -21,7 +22,7 @@ import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
inject: [ConfigService],
|
inject: [ConfigService],
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
providers: [MailsService],
|
providers: [MailsService, MailProcessor],
|
||||||
exports: [MailsService],
|
exports: [MailsService],
|
||||||
})
|
})
|
||||||
export class MailsModule {}
|
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 { Injectable } from '@nestjs/common';
|
||||||
import { MailerService } from '@nestjs-modules/mailer';
|
import { MailerService } from '@nestjs-modules/mailer';
|
||||||
import { ScrapItem } from '@/modules/scraps/entities/scrap-item.entity';
|
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()
|
@Injectable()
|
||||||
export class MailsService {
|
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) {
|
async sendPlainText(to: string, subject: string, content: string) {
|
||||||
await this.mailerService.sendMail({
|
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) {
|
async sendPlainHtml(to: string, subject: string, html: string) {
|
||||||
const emails = to
|
const emails = to
|
||||||
.split(',')
|
.split(',')
|
||||||
|
|
@ -33,17 +52,34 @@ export class MailsService {
|
||||||
}
|
}
|
||||||
|
|
||||||
generateProductTableHTML(products: ScrapItem[]): string {
|
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
|
const rows = products
|
||||||
.map(
|
.map(
|
||||||
(p) => `
|
(p) => `
|
||||||
<tr>
|
<tr>
|
||||||
<td><img src="${p.image_url}" alt="Product Image" style="height: 40px; object-fit: contain; border-radius: 4px;" /></td>
|
<td><img src="${p.image_url}" alt="Product Image" style="height: 40px; object-fit: contain; border-radius: 4px;" /></td>
|
||||||
<td>${p.name}</td>
|
<td>${p.name}</td>
|
||||||
<td style="font-weight: bold; color: #e03131;">$${p.current_price}</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><a href="${p.url}" target="_blank" style="color: #007bff;">View</a></td>
|
||||||
<td>${extractDomain(p.scrap_config.web_bid.origin_url)}</td>
|
<td>${extractDomainSmart(p.scrap_config.web_bid.origin_url)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
`,
|
`,
|
||||||
)
|
)
|
||||||
.join('');
|
.join('');
|
||||||
|
|
||||||
|
|
@ -78,12 +114,197 @@ export class MailsService {
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendWithTemplate(to: string, subject: string, payload: any) {
|
getAuctionStatusEmailContent(bid: Bid): string {
|
||||||
await this.mailerService.sendMail({
|
const webname = extractDomain(bid.web_bid.origin_url);
|
||||||
to,
|
const title = `[${webname}] ${bid.name || 'Unnamed Item'}`;
|
||||||
subject,
|
const endTime = formatEndTime(bid.close_time, false);
|
||||||
template: './welcome', // đường dẫn tương đối trong /templates
|
const competitor = `$${bid.current_price}`;
|
||||||
context: payload, // dữ liệu cho template
|
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 = {
|
// export const NAME_EVENTS = {
|
||||||
BID_STATUS: 'notify.bid-status',
|
// BID_STATUS: 'notify.bid-status',
|
||||||
};
|
// };
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,8 @@ export class ClientNotificationController {
|
||||||
@Post('test')
|
@Post('test')
|
||||||
async test() {
|
async test() {
|
||||||
const bid = await this.bidsService.bidsRepo.findOne({
|
const bid = await this.bidsService.bidsRepo.findOne({
|
||||||
where: { lot_id: '26077023' },
|
where: { lot_id: '23755862' },
|
||||||
|
relations: { web_bid: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
return await this.notifyService.emitBidStatus({
|
return await this.notifyService.emitBidStatus({
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,23 @@
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { OnEvent } from '@nestjs/event-emitter';
|
import { OnEvent } from '@nestjs/event-emitter';
|
||||||
import { NAME_EVENTS } from '../constants';
|
|
||||||
import { Bid } from '@/modules/bids/entities/bid.entity';
|
import { Bid } from '@/modules/bids/entities/bid.entity';
|
||||||
import { Notification } from '../entities/notification.entity';
|
import { Notification } from '../entities/notification.entity';
|
||||||
import { BotTelegramApi } from '@/modules/bids/apis/bot-telegram.api';
|
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()
|
@Injectable()
|
||||||
export class AdminNotificationListener {
|
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)
|
@OnEvent(Event.BID_STATUS)
|
||||||
handleBidStatus({
|
async handleBidStatus({
|
||||||
bid,
|
bid,
|
||||||
notification,
|
notification,
|
||||||
}: {
|
}: {
|
||||||
|
|
@ -20,5 +27,30 @@ export class AdminNotificationListener {
|
||||||
if (JSON.parse(notification.send_to).length <= 0) return;
|
if (JSON.parse(notification.send_to).length <= 0) return;
|
||||||
|
|
||||||
this.botTelegramApi.sendMessage(notification.message);
|
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 { NotificationService } from './notification.service';
|
||||||
import { SendMessageHistoriesService } from '../bids/services/send-message-histories.service';
|
import { SendMessageHistoriesService } from '../bids/services/send-message-histories.service';
|
||||||
import { SendMessageHistory } from '../bids/entities/send-message-histories.entity';
|
import { SendMessageHistory } from '../bids/entities/send-message-histories.entity';
|
||||||
|
import { MailsModule } from '../mails/mails.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
forwardRef(() => BidsModule),
|
forwardRef(() => BidsModule),
|
||||||
TypeOrmModule.forFeature([Notification, SendMessageHistory]),
|
TypeOrmModule.forFeature([Notification, SendMessageHistory]),
|
||||||
|
MailsModule,
|
||||||
],
|
],
|
||||||
controllers: [NotificationController, ClientNotificationController],
|
controllers: [NotificationController, ClientNotificationController],
|
||||||
providers: [NotificationService, AdminNotificationListener],
|
providers: [NotificationService, AdminNotificationListener],
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||||
import { Bid } from '../bids/entities/bid.entity';
|
import { Bid } from '../bids/entities/bid.entity';
|
||||||
import { NAME_EVENTS } from './constants';
|
|
||||||
import { BotTelegramApi } from '../bids/apis/bot-telegram.api';
|
import { BotTelegramApi } from '../bids/apis/bot-telegram.api';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Notification } from './entities/notification.entity';
|
import { Notification } from './entities/notification.entity';
|
||||||
|
|
@ -17,6 +16,7 @@ import { Column } from 'nestjs-paginate/lib/helper';
|
||||||
import AppResponse from '@/response/app-response';
|
import AppResponse from '@/response/app-response';
|
||||||
import { SendMessageHistoriesService } from '../bids/services/send-message-histories.service';
|
import { SendMessageHistoriesService } from '../bids/services/send-message-histories.service';
|
||||||
import { SendMessageHistory } from '../bids/entities/send-message-histories.entity';
|
import { SendMessageHistory } from '../bids/entities/send-message-histories.entity';
|
||||||
|
import { Event } from '../bids/utils/events';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class NotificationService {
|
export class NotificationService {
|
||||||
|
|
@ -116,7 +116,7 @@ export class NotificationService {
|
||||||
message: notification.message,
|
message: notification.message,
|
||||||
type: bid.status,
|
type: bid.status,
|
||||||
max_price: bid.max_price,
|
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,
|
message: notification.message,
|
||||||
type: bid.status,
|
type: bid.status,
|
||||||
max_price: bid.max_price,
|
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: {
|
||||||
...bid,
|
...bid,
|
||||||
status: 'out-bid',
|
// status: 'out-bid',
|
||||||
},
|
},
|
||||||
notification,
|
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, Param, Post, Put } from '@nestjs/common';
|
||||||
import { Body, Controller, Get, Param, Post, Put } from '@nestjs/common';
|
|
||||||
import { Between, IsNull, Not } from 'typeorm';
|
|
||||||
import { CreateScrapConfigDto } from '../dto/scrap-config/create-scrap-config';
|
import { CreateScrapConfigDto } from '../dto/scrap-config/create-scrap-config';
|
||||||
import { UpdateScrapConfigDto } from '../dto/scrap-config/update-scrap-config';
|
import { UpdateScrapConfigDto } from '../dto/scrap-config/update-scrap-config';
|
||||||
import { ScrapConfig } from '../entities/scrap-config.entity';
|
import { ScrapConfig } from '../entities/scrap-config.entity';
|
||||||
import { ScrapConfigsService } from '../services/scrap-config.service';
|
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')
|
@Controller('admin/scrap-configs')
|
||||||
export class ScrapConfigsController {
|
export class ScrapConfigsController {
|
||||||
constructor(
|
constructor(private readonly scrapConfigsService: ScrapConfigsService) {}
|
||||||
private readonly scrapConfigsService: ScrapConfigsService,
|
|
||||||
private readonly scrapItemsService: ScrapItemsService,
|
|
||||||
private readonly mailsService: MailsService,
|
|
||||||
private readonly configsSerivce: ConfigsService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
async create(@Body() data: CreateScrapConfigDto) {
|
async create(@Body() data: CreateScrapConfigDto) {
|
||||||
|
|
@ -30,56 +20,4 @@ export class ScrapConfigsController {
|
||||||
) {
|
) {
|
||||||
return await this.scrapConfigsService.update(id, data);
|
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' };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,19 @@
|
||||||
import { IsNumber, IsOptional, IsString, IsUrl } from 'class-validator';
|
import {
|
||||||
|
IsBoolean,
|
||||||
|
IsNumber,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
IsUrl,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
export class CreateScrapConfigDto {
|
export class CreateScrapConfigDto {
|
||||||
@IsUrl()
|
@IsUrl()
|
||||||
search_url: string;
|
search_url: string;
|
||||||
|
|
||||||
|
@IsBoolean()
|
||||||
|
@IsOptional()
|
||||||
|
enable: boolean;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
keywords: string;
|
keywords: string;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -22,6 +22,9 @@ export class ScrapConfig extends Timestamp {
|
||||||
@Column({ default: 'cisco' })
|
@Column({ default: 'cisco' })
|
||||||
keywords: string;
|
keywords: string;
|
||||||
|
|
||||||
|
@Column({ default: true })
|
||||||
|
enable: boolean;
|
||||||
|
|
||||||
@OneToOne(() => WebBid, (web) => web.scrap_config, { onDelete: 'CASCADE' })
|
@OneToOne(() => WebBid, (web) => web.scrap_config, { onDelete: 'CASCADE' })
|
||||||
@JoinColumn()
|
@JoinColumn()
|
||||||
web_bid: WebBid;
|
web_bid: WebBid;
|
||||||
|
|
|
||||||
|
|
@ -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 { ScrapItemsService } from './services/scrap-item-config.service';
|
||||||
import { MailsModule } from '../mails/mails.module';
|
import { MailsModule } from '../mails/mails.module';
|
||||||
import { BidsModule } from '../bids/bids.module';
|
import { BidsModule } from '../bids/bids.module';
|
||||||
|
import { ClientScrapConfigsController } from './controllers/client/scrap-configs.controller';
|
||||||
|
import { ClientScrapItemsController } from './controllers/client/scrap-items.controller';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
|
@ -17,6 +19,10 @@ import { BidsModule } from '../bids/bids.module';
|
||||||
],
|
],
|
||||||
providers: [ScrapConfigsService, TasksService, ScrapItemsService],
|
providers: [ScrapConfigsService, TasksService, ScrapItemsService],
|
||||||
exports: [ScrapConfigsService, TasksService, ScrapItemsService],
|
exports: [ScrapConfigsService, TasksService, ScrapItemsService],
|
||||||
controllers: [ScrapConfigsController],
|
controllers: [
|
||||||
|
ScrapConfigsController,
|
||||||
|
ClientScrapConfigsController,
|
||||||
|
ClientScrapItemsController,
|
||||||
|
],
|
||||||
})
|
})
|
||||||
export class ScrapsModule {}
|
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 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 { CreateScrapConfigDto } from '../dto/scrap-config/create-scrap-config';
|
||||||
import { UpdateScrapConfigDto } from '../dto/scrap-config/update-scrap-config';
|
import { UpdateScrapConfigDto } from '../dto/scrap-config/update-scrap-config';
|
||||||
import axios from 'axios';
|
import { ScrapConfig } from '../entities/scrap-config.entity';
|
||||||
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';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ScrapConfigsService {
|
export class ScrapConfigsService {
|
||||||
|
|
@ -18,6 +14,21 @@ export class ScrapConfigsService {
|
||||||
readonly scrapConfigRepo: Repository<ScrapConfig>,
|
readonly scrapConfigRepo: Repository<ScrapConfig>,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
async clientGetScrapeConfigs() {
|
||||||
|
const data = await this.scrapConfigRepo.find({
|
||||||
|
where: {
|
||||||
|
search_url: Not(IsNull()),
|
||||||
|
keywords: Not(IsNull()),
|
||||||
|
enable: true,
|
||||||
|
},
|
||||||
|
relations: {
|
||||||
|
web_bid: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return AppResponse.toResponse(plainToClass(ScrapConfig, data));
|
||||||
|
}
|
||||||
|
|
||||||
async create(data: CreateScrapConfigDto) {
|
async create(data: CreateScrapConfigDto) {
|
||||||
const result = await this.scrapConfigRepo.save({
|
const result = await this.scrapConfigRepo.save({
|
||||||
search_url: data.search_url,
|
search_url: data.search_url,
|
||||||
|
|
@ -40,22 +51,4 @@ export class ScrapConfigsService {
|
||||||
|
|
||||||
return AppResponse.toResponse(true);
|
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 { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { ScrapItem } from '../entities/scrap-item.entity';
|
import { ScrapItem } from '../entities/scrap-item.entity';
|
||||||
|
import AppResponse from '@/response/app-response';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ScrapItemsService {
|
export class ScrapItemsService {
|
||||||
|
|
@ -58,4 +59,12 @@ export class ScrapItemsService {
|
||||||
updated: toUpdate.length,
|
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,72 +1,95 @@
|
||||||
|
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 { Injectable, Logger } from '@nestjs/common';
|
||||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
import { Cron } from '@nestjs/schedule';
|
||||||
import { Between, IsNull, Not } from 'typeorm';
|
import * as moment from 'moment';
|
||||||
|
import { Between } from 'typeorm';
|
||||||
import { ScrapConfigsService } from './scrap-config.service';
|
import { ScrapConfigsService } from './scrap-config.service';
|
||||||
import { ScrapItemsService } from './scrap-item-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()
|
@Injectable()
|
||||||
export class TasksService {
|
export class TasksService {
|
||||||
private readonly logger = new Logger(TasksService.name);
|
private readonly logger = new Logger(TasksService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly scrapConfigsService: ScrapConfigsService,
|
|
||||||
private readonly scrapItemsService: ScrapItemsService,
|
private readonly scrapItemsService: ScrapItemsService,
|
||||||
private readonly mailsService: MailsService,
|
private readonly mailsService: MailsService,
|
||||||
private readonly configsSerivce: ConfigsService,
|
private readonly configsSerivce: ConfigsService,
|
||||||
|
private readonly dashboardService: DashboardService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Cron('0 2 * * *')
|
async runProcessAndSendReport(processName: string) {
|
||||||
async handleScraps() {
|
|
||||||
const mails = (await this.configsSerivce.getConfig('MAIL_SCRAP_REPORT'))
|
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({
|
// Reset và chạy process
|
||||||
where: {
|
await this.dashboardService.resetProcessByName(processName);
|
||||||
search_url: Not(IsNull()),
|
console.log(`Process ${processName} started.`);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
|
// Đợ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();
|
const startOfDay = new Date();
|
||||||
startOfDay.setHours(0, 0, 0, 0);
|
startOfDay.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
const endOfDay = new Date();
|
const endOfDay = new Date();
|
||||||
endOfDay.setHours(23, 59, 59, 999);
|
endOfDay.setHours(23, 59, 59, 999);
|
||||||
|
|
||||||
const data = await this.scrapItemsService.scrapItemRepo.find({
|
try {
|
||||||
where: {
|
const data = await this.scrapItemsService.scrapItemRepo.find({
|
||||||
updated_at: Between(startOfDay, endOfDay),
|
where: {
|
||||||
},
|
updated_at: Between(startOfDay, endOfDay),
|
||||||
relations: { scrap_config: { web_bid: true } },
|
},
|
||||||
order: { updated_at: 'DESC' },
|
relations: { scrap_config: { web_bid: true } },
|
||||||
});
|
order: { updated_at: 'DESC' },
|
||||||
|
});
|
||||||
|
|
||||||
await this.mailsService.sendPlainHtml(
|
await this.mailsService.sendHtmlMailJob({
|
||||||
mails,
|
to: mails,
|
||||||
`Auction Items Matching Your Keywords – Daily Update ${moment(new Date()).format('YYYY-MM-DD HH:mm')}`,
|
subject: `Auction Items Matching Your Keywords – Daily Update ${moment().format('YYYY-MM-DD HH:mm')}`,
|
||||||
this.mailsService.generateProductTableHTML(data),
|
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 { Bid } from '@/modules/bids/entities/bid.entity';
|
||||||
|
import * as moment from 'moment';
|
||||||
|
|
||||||
export function extractModelId(url: string): string | null {
|
export function extractModelId(url: string): string | null {
|
||||||
switch (extractDomain(url)) {
|
switch (extractDomain(url)) {
|
||||||
|
|
@ -31,6 +32,25 @@ export function subtractMinutes(timeStr: string, minutes: number) {
|
||||||
return date.toISOString(); // Trả về dạng chuẩn ISO
|
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) {
|
export function subtractSeconds(time: string, seconds: number) {
|
||||||
const date = new Date(time);
|
const date = new Date(time);
|
||||||
date.setSeconds(date.getSeconds() - seconds);
|
date.setSeconds(date.getSeconds() - seconds);
|
||||||
|
|
@ -185,3 +205,13 @@ export function extractNumber(str: string) {
|
||||||
const match = str.match(/\d+(\.\d+)?/);
|
const match = str.match(/\d+(\.\d+)?/);
|
||||||
return match ? parseFloat(match[0]) : null;
|
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