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