update extension, grays flow, reset tool task

This commit is contained in:
Admin 2025-05-16 09:56:39 +07:00
parent 4d2c39c053
commit 42f0a881f4
26 changed files with 1057 additions and 69 deletions

BIN
.DS_Store vendored

Binary file not shown.

View File

@ -1 +1 @@
{"createdAt":1747191706164}
{"createdAt":1747292824357}

View File

@ -30,6 +30,7 @@
"dayjs": "^1.11.13",
"imap": "^0.8.19",
"lodash": "^4.17.21",
"moment": "^2.30.1",
"multer": "^1.4.5-lts.1",
"mysql2": "^3.13.0",
"nestjs-paginate": "^11.1.0",
@ -8660,9 +8661,10 @@
}
},
"node_modules/moment": {
"version": "2.8.3",
"resolved": "https://registry.npmjs.org/moment/-/moment-2.8.3.tgz",
"integrity": "sha512-BV9tebhfgcO0mT3desIQEM8vdFGOsqwfJ71a+mz/ECVa0uiokosBq3A3Vyk7XhyNUzzQECX/T/bpHQANemf5Vg==",
"version": "2.30.1",
"resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
"integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==",
"license": "MIT",
"engines": {
"node": "*"
}
@ -10318,6 +10320,14 @@
"integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==",
"license": "MIT"
},
"node_modules/simple-imap/node_modules/moment": {
"version": "2.8.3",
"resolved": "https://registry.npmjs.org/moment/-/moment-2.8.3.tgz",
"integrity": "sha512-BV9tebhfgcO0mT3desIQEM8vdFGOsqwfJ71a+mz/ECVa0uiokosBq3A3Vyk7XhyNUzzQECX/T/bpHQANemf5Vg==",
"engines": {
"node": "*"
}
},
"node_modules/simple-imap/node_modules/readable-stream": {
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz",

View File

@ -46,6 +46,7 @@
"dayjs": "^1.11.13",
"imap": "^0.8.19",
"lodash": "^4.17.21",
"moment": "^2.30.1",
"multer": "^1.4.5-lts.1",
"mysql2": "^3.13.0",
"nestjs-paginate": "^11.1.0",

View File

@ -5,14 +5,27 @@ import { BadRequestExceptionFilter } from './system/filters/bad-request-exceptio
import { IoAdapter } from '@nestjs/platform-socket.io';
import * as cookieParser from 'cookie-parser';
import { useContainer } from 'class-validator';
import { WebBidsService } from './modules/bids/services/web-bids.service';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const prefix_version = '/api/v1';
const webBidService = app.get<WebBidsService>(WebBidsService);
const webs = await webBidService.webBidRepo.find();
const urls = webs.map((web) => web.origin_url);
const corsOrigins = process.env.CORS
? process.env.CORS.split(',').map((origin) => origin.trim())
: ['*']; // hoặc [] tùy ý bạn
console.log('%csrc/main.ts:26 [...corsOrigins, ...urls]', 'color: #007acc;', [...corsOrigins, ...urls]);
app.enableCors({
origin: process.env.CORS.split(', ') || '*',
origin: [...corsOrigins, ...urls],
methods: ['GET', 'PUT', 'POST', 'DELETE', 'PATCH'],
allowedHeaders: 'Content-Type, Authorization',
credentials: true,

View File

@ -103,4 +103,5 @@ export class GenerateKeysService {
return AppResponse.toResponse(result);
}
}

View File

@ -22,6 +22,8 @@ import { WebBidsService } from '../../services/web-bids.service';
import { Event } from '../../utils/events';
import AppResponse from '@/response/app-response';
import { ClientUpdateLoginStatusDto } from '../../dto/bid/client-update-login-status.dto';
import { UpdateBidDto } from '../../dto/bid/update-bid.dto';
import { ClientInfoUpdateBidDto } from '../../dto/bid/client-info-update-bid.dto';
@Controller('bids')
export class BidsController {
@ -36,9 +38,14 @@ export class BidsController {
return this.bidsService.clientIndex();
}
@Get(':model')
getByModel(@Param('model') model: Bid['model']) {
return this.bidsService.getBidByModel(model);
}
@Post()
create(@Body() data: CreateBidDto) {
return this.bidsService.create(data);
return this.bidsService.create(data, (result) => !!result);
}
@Put(':id')
@ -46,6 +53,11 @@ export class BidsController {
return this.bidsService.clientUpdate(id, data);
}
@Put('info/:id')
updateInfo(@Param('id') id: Bid['id'], @Body() data: ClientInfoUpdateBidDto) {
return this.bidsService.update(id, data);
}
@Post('out-bid/:id')
outBidStatus(@Param('id') id: Bid['id']) {
return this.bidsService.outBid(id);
@ -70,10 +82,8 @@ export class BidsController {
}
@Post('update-login-status')
async updateLoginStatus(
@Body() data: ClientUpdateLoginStatusDto
) {
return await this.bidsService.emitLoginStatus(data)
async updateLoginStatus(@Body() data: ClientUpdateLoginStatusDto) {
return await this.bidsService.emitLoginStatus(data);
}
@Post('test')

View File

@ -0,0 +1,15 @@
import { IsNumber, IsOptional } from 'class-validator';
export class ClientInfoUpdateBidDto {
@IsNumber()
@IsOptional()
max_price: number;
@IsNumber()
@IsOptional()
quantity: number;
@IsNumber()
@IsOptional()
plus_price: number;
}

View File

@ -1,9 +1,16 @@
import { Column, Entity, ManyToOne, PrimaryGeneratedColumn, Unique } from 'typeorm';
import {
Column,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
Unique,
} from 'typeorm';
import { Timestamp } from './timestamp';
import { Bid } from './bid.entity';
@Entity('send_message_histories')
@Unique(['max_price', 'type', 'reserve_price'])
@Unique(['max_price', 'type', 'reserve_price', 'bid'])
export class SendMessageHistory extends Timestamp {
@PrimaryGeneratedColumn('increment')
id: number;

View File

@ -1,3 +1,4 @@
import { NotificationService } from '@/modules/notification/notification.service';
import {
BadRequestException,
HttpStatus,
@ -21,6 +22,7 @@ import AppResponse from 'src/response/app-response';
import { extractModelId, isTimeReached, subtractMinutes } from 'src/ultils';
import { In, IsNull, Not, Repository } from 'typeorm';
import { ClientUpdateBidDto } from '../dto/bid/client-update-bid.dto';
import { ClientUpdateLoginStatusDto } from '../dto/bid/client-update-login-status.dto';
import { CreateBidDto } from '../dto/bid/create-bid.dto';
import { UpdateBidDto } from '../dto/bid/update-bid.dto';
import { UpdateStatusByPriceDto } from '../dto/bid/update-status-by-price.dto';
@ -28,11 +30,8 @@ import { BidHistory } from '../entities/bid-history.entity';
import { Bid } from '../entities/bid.entity';
import { ImageCompressionPipe } from '../pipes/image-compression-pipe';
import { Constant } from '../utils/constant';
import { WebBidsService } from './web-bids.service';
import { NotificationService } from '@/modules/notification/notification.service';
import { Event } from '../utils/events';
import _ from 'lodash';
import { ClientUpdateLoginStatusDto } from '../dto/bid/client-update-login-status.dto';
import { WebBidsService } from './web-bids.service';
@Injectable()
export class BidsService {
@ -100,7 +99,7 @@ export class BidsService {
await this.webBidsService.emitAllBidEvent();
}
async create(data: CreateBidDto) {
async create(data: CreateBidDto, response?: (result: Bid) => any) {
const model = extractModelId(data.url);
const prev = await this.bidsRepo.findOne({ where: { model: model } });
@ -121,7 +120,7 @@ export class BidsService {
await this.emitAllBidEvent();
return AppResponse.toResponse(plainToClass(Bid, result));
return AppResponse.toResponse(response ? response(result) : plainToClass(Bid, result));
}
async update(id: Bid['id'], data: UpdateBidDto) {
@ -226,7 +225,7 @@ export class BidsService {
);
// Nếu phiên đấu giá chưa có thời gian bắt đầu và kết thúc
if (!bid.close_time && !bid.start_bid_time) {
if (!bid.close_time && !bid.start_bid_time && data?.lot_id) {
// Thiết lập thời gian bắt đầu là 5 phút trước khi đóng
// bid.start_bid_time = new Date().toUTCString();
bid.start_bid_time = subtractMinutes(
@ -549,4 +548,14 @@ export class BidsService {
return nextBid;
}
async getBidByModel(model: string) {
console.log('%csrc/modules/bids/services/bids.service.ts:554 model', 'color: #007acc;', model);
const bid = await this.bidsRepo.findOne({ where: { model } });
if (!bid) return AppResponse.toResponse(null, {status_code: HttpStatus.NOT_FOUND});
return AppResponse.toResponse(plainToClass(Bid, bid));
}
}

View File

@ -20,9 +20,9 @@ export class ConfigsService {
return await this.configRepo.findOne({ where: { key_name } }) || null;
}
async setConfig(key_name: keyof typeof ConfigsService.CONFIG_KEYS, value: string) {
async setConfig(key_name: keyof typeof ConfigsService.CONFIG_KEYS, value: string, type: 'string' | 'number') {
return await this.configRepo.upsert(
{ key_name, value },
{ key_name, value , type},
['key_name']
);
}

View File

@ -2,9 +2,16 @@ import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { IsNull, Not } from 'typeorm';
import { BidsService } from './bids.service';
import { isTimeReached, subtractMinutes, subtractSeconds } from '@/ultils';
import * as moment from 'moment';
import {
isTimeReached,
shouldResetTool,
subtractMinutes,
subtractSeconds,
} from '@/ultils';
import { ConfigsService } from './configs.service';
import { DashboardService } from './dashboard.service';
import { Bid } from '../entities/bid.entity';
@Injectable()
export class TasksService {
private readonly logger = new Logger(TasksService.name);
@ -17,52 +24,57 @@ export class TasksService {
@Cron(CronExpression.EVERY_MINUTE)
async handleCron() {
const nextBid = await this.bidsService.getNextBid();
const bids = await this.bidsService.bidsRepo.find({
where: { status: 'biding' },
select: ['close_time', 'created_at', 'start_bid_time', 'id', 'lot_id'],
});
if (!nextBid) return;
const timeReset = subtractMinutes(nextBid.close_time, 20);
const timeToTracking = subtractSeconds(
nextBid.close_time,
nextBid.web_bid.early_tracking_seconds,
);
if (!isTimeReached(timeReset) || isTimeReached(timeToTracking)) {
console.log('Reset not allowed at this time');
return;
}
const lastestResetToolTime =
const lastResetTimeRaw =
await this.configsService.getConfig('REFRESH_TOOL_TIME');
const lastResetTime = lastResetTimeRaw?.value
? new Date(lastResetTimeRaw.value)
: null;
const now = new Date();
if (lastestResetToolTime?.value) {
const lastReset = Date.parse(lastestResetToolTime.value);
const now = Date.now();
const diffInMs = now - lastReset;
const diffInHours = diffInMs / (1000 * 60 * 60);
const minimumHours = 2;
if (diffInHours < minimumHours) {
console.log(`Last reset was less than ${minimumHours} hours ago`);
return;
}
}
console.log('Proceeding to reset tool for next bid:', nextBid);
await this.dashboadService.resetTool();
const time = new Date().toUTCString()
await this.configsService.setConfig(
'REFRESH_TOOL_TIME',
time,
console.log('===== Reset Tool Check =====');
console.log(
'Last reset time:',
lastResetTime
? moment(lastResetTime).format('YYYY-MM-DD HH:mm:ss')
: 'null',
);
console.log('Current time:', moment(now).format('YYYY-MM-DD HH:mm:ss'));
console.log('Reset successfully at: ' + time);
const result = shouldResetTool(bids, lastResetTime, now);
if (result.shouldReset) {
console.log('Should reset due to:', result.reason);
if (result.bidId) {
console.log('Related bid id:', result.bidId);
if (result.closeTime) {
const closeTimeDate = new Date(result.closeTime);
const expectedReset = moment(closeTimeDate)
.subtract(20, 'minutes')
.format('YYYY-MM-DD HH:mm:ss');
console.log(
'Expected reset time (close_time - 20 minutes):',
expectedReset,
);
}
}
await this.dashboadService.resetTool();
await this.configsService.setConfig(
'REFRESH_TOOL_TIME',
now.toISOString(),
'string',
);
console.log('Tool reset at:', moment(now).format('YYYY-MM-DD HH:mm:ss'));
} else {
console.log('No reset needed at this time');
}
}
}

View File

@ -1,3 +1,5 @@
import { Bid } from "@/modules/bids/entities/bid.entity";
export function extractModelId(url: string): string | null {
switch (extractDomain(url)) {
case 'https://www.grays.com': {
@ -107,3 +109,72 @@ export function verifyCode(content: string) {
// Nếu không tìm thấy mã xác minh, trả về null
return null;
}
export function shouldResetTool(
bids: Bid[],
lastResetTime: Date | null,
now: Date = new Date(),
) {
const ONE_MINUTE = 60 * 1000;
const ONE_HOUR = 60 * ONE_MINUTE;
const TWO_HOURS = 2 * ONE_HOUR;
const TWENTY_MINUTES = 20 * ONE_MINUTE;
const FIVE_MINUTES = 5 * ONE_MINUTE;
// Nếu đã reset trong 1 giờ gần đây => không reset
if (lastResetTime && now.getTime() - lastResetTime.getTime() < ONE_HOUR) {
return {
shouldReset: false,
};
}
// 1. Kiểm tra bid gần nhất có close_time trong vòng 20 phút tới
const futureBids = bids
.filter((b) => b.close_time)
.map((b) => ({
...b,
closeTime: new Date(b.close_time!),
}))
.filter((b) => b.closeTime.getTime() > now.getTime())
.sort((a, b) => a.closeTime.getTime() - b.closeTime.getTime());
const closest = futureBids[0];
const hasBidCloseSoon =
closest && closest.closeTime.getTime() - now.getTime() <= TWENTY_MINUTES;
if (hasBidCloseSoon) {
return {
shouldReset: true,
reason: 'Bid close_time is within 20 minutes',
bidId: closest.id,
closeTime: closest.close_time
};
}
// 2. Kiểm tra bid chưa có lot_id hoặc close_time, được tạo > 5 phút và cách reset trước > 2 tiếng
// Đồng thời không có bid nào sắp close trong 20 phút tới
if (!hasBidCloseSoon) {
for (const bid of bids) {
const createdAt = new Date(bid.created_at);
if (
(!bid.lot_id || !bid.close_time) &&
now.getTime() - createdAt.getTime() > FIVE_MINUTES &&
(!lastResetTime ||
now.getTime() - lastResetTime.getTime() > TWO_HOURS)
) {
return {
shouldReset: true,
reason:
'Bid is missing info and older than 5 mins, last reset > 2h, and no urgent bids',
bidId: bid.id,
closeTime: bid.close_time
};
}
}
}
return {
shouldReset: false,
};
}

View File

@ -84,6 +84,9 @@ export class GrayApiBid extends ApiBid {
configs.WEB_CONFIGS.GRAYS.AUTO_CALL_API_TO_TRACKING,
this.id
);
// extractLotsAndBids
await this.extractLotsAndBids();
} catch (error) {
if (error.message.includes("Execution context was destroyed")) {
console.log(
@ -172,6 +175,32 @@ export class GrayApiBid extends ApiBid {
return false;
};
async extractLotsAndBids(url) {
if (!this.page_context) return;
const data = await this.page_context.evaluate(() => {
const rows = document.querySelectorAll("tr.current.open");
return Array.from(rows).map((row) => {
const lotText =
row.querySelector(".lot-number")?.textContent.trim() || "";
const autobidMatch = row.textContent.match(
/Your maximum autobid is AU\s*\$([\d,]+)/
);
const lotMatch = lotText.match(/Lot No:\s*(\S+)/);
return {
model: lotMatch ? lotMatch[1] : null,
max_price: autobidMatch
? parseFloat(autobidMatch[1].replace(/,/g, ""))
: null,
};
});
});
global[`BIDED_DATA_${this.origin_url}`] = data || [];
}
async handleLogin() {
const page = this.page_context;

View File

@ -1,18 +1,13 @@
import {
outBid,
pushPrice,
updateBid,
updateStatusByPrice,
} from "../../system/apis/bid.js";
import CONSTANTS from "../../system/constants.js";
import {
delay,
extractNumber,
isNumber,
isTimeReached,
removeFalsyValues,
safeClosePage,
takeSnapshot,
} from "../../system/utils.js";
import { ProductBid } from "../product-bid.js";
@ -65,6 +60,12 @@ export class GraysProductBid extends ProductBid {
async isCloseProduct() {
const close_time = await this.getCloseTime();
const currentUrl = await this.page_context.url();
if (currentUrl !== this.url) {
return { result: false, close_time };
}
if (!close_time) {
const priceWasBid = await this.getPriceWasBid();
@ -130,6 +131,7 @@ export class GraysProductBid extends ProductBid {
return true;
} catch (error) {
this.page_context.goto(this.url);
console.log(`❌ [${this.id}] Error in placeBid: ${error.message}`);
return false;
}
@ -301,7 +303,6 @@ export class GraysProductBid extends ProductBid {
);
return;
}
const page = this.page_context;
if (global[`IS_PLACE_BID-${this.id}`]) {
console.log(`⚠️ [${this.id}] Bid is already in progress, skipping.`);
@ -316,17 +317,50 @@ export class GraysProductBid extends ProductBid {
global.IS_CLEANING = false;
const isCloseProduct = await this.isCloseProduct();
if (isCloseProduct.result) {
console.log(
`⚠️ [${this.id}] Outbid detected, calling outBid function.`
);
await outBid(this.id);
return;
}
const biddedData = this.getBidedData();
console.log({
biddedData,
});
const isBided = biddedData.find(
(item) => item.model === this.model && item.max_price === this.max_price
);
if (isBided) {
console.log(`[${this.id}] This item bided. Skipping...`);
global[`IS_PLACE_BID-${this.id}`] = false;
global.IS_CLEANING = true;
return;
}
if (!this.start_bid_time || !this.close_time) {
console.log(
`[${this.id}] Skipping processing: auction has started but not yet closed.`
);
global.IS_CLEANING = true;
return;
}
// Kiểm tra nếu giá hiện tại lớn hơn giá tối đa cộng thêm giá cộng thêm
if (this.current_price > this.max_price + this.plus_price) {
console.log(`⚠️ [${this.id}] Outbid bid`); // Ghi log cảnh báo nếu giá hiện tại vượt quá mức tối đa cho phép
global.IS_CLEANING = true;
return; // Dừng hàm nếu giá đã vượt qua giới hạn
}
@ -337,6 +371,8 @@ export class GraysProductBid extends ProductBid {
this.name || "None"
}`
);
global.IS_CLEANING = true;
return;
}
@ -347,6 +383,9 @@ export class GraysProductBid extends ProductBid {
return;
}
console.log(
"-------------------------------------BIDING---------------------------------------"
);
const result = await this.placeBid();
global.IS_CLEANING = true;
@ -358,6 +397,10 @@ export class GraysProductBid extends ProductBid {
}
}
getBidedData() {
return global[`BIDED_DATA_${this.web_bid?.origin_url}`];
}
action = async () => {
try {
const page = this.page_context;

BIN
bid-extension/.DS_Store vendored Normal file

Binary file not shown.

View File

@ -0,0 +1,134 @@
/* #bid-extension body {
margin: 0;
padding: 0;
background-color: #121212;
color: #e0e0e0;
font-family: 'Segoe UI', Tahoma, sans-serif;
width: 320px;
} */
#bid-extension {
margin: 0;
padding: 0;
background-color: #121212;
color: #e0e0e0;
font-family: "Segoe UI", Tahoma, sans-serif;
width: 320px;
}
#bid-extension .container {
display: flex;
flex-direction: column;
gap: 10px;
}
#bid-extension h2 {
text-align: center;
margin-bottom: 12px;
font-size: 22px;
color: #ffffff;
}
#bid-extension label {
font-size: 13px;
margin-bottom: 2px;
}
#bid-extension input,
#bid-extension textarea {
padding: 8px;
background-color: #1e1e1e;
color: #ffffff;
border: 1px solid #333;
border-radius: 6px;
font-size: 14px;
width: 100%;
box-sizing: border-box;
}
#bid-extension input:focus,
#bid-extension textarea:focus {
border-color: #4a90e2;
outline: none;
}
#bid-extension .row {
display: flex;
gap: 10px;
}
#bid-extension .col {
flex: 1;
display: flex;
flex-direction: column;
}
#bid-extension .inputs .col {
padding-left: 0px;
padding-right: 0px;
}
#bid-extension button {
margin-top: 10px;
padding: 10px;
background: linear-gradient(to right, #4a90e2, #357abd);
color: white;
border: none;
border-radius: 6px;
font-size: 15px;
cursor: pointer;
transition: background 0.3s ease;
}
#bid-extension button:hover {
background: linear-gradient(to right, #3a78c2, #2d5faa);
}
#bid-extension #errorMessage {
margin-top: 2px;
font-size: 11px;
}
#bid-extension .wrapper {
position: relative;
}
#bid-extension .key-container {
position: absolute;
top: 20px;
left: 20px;
}
#bid-extension .key-container a {
color: #ffffff;
font-size: 14px;
padding: 4px 10px;
background: linear-gradient(to right, #4a90e2, #357abd);
border-radius: 6px;
text-decoration: none;
display: inline-block;
transition: all 0.3s ease;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.2);
}
#bid-extension .key-container a:hover {
background: linear-gradient(to right, #3a78c2, #2d5faa);
box-shadow: 0 6px 10px rgba(0, 0, 0, 0.3);
transform: translateY(-2px);
}
#bid-extension .inputs {
display: flex;
flex-direction: column;
gap: 8px;
}
#bid-extension svg {
width: 14px;
height: 14px;
}
#toggle-bid-extension svg {
width: 20px;
height: 20px;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 305 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 522 B

View File

@ -0,0 +1,4 @@
chrome.action.onClicked.addListener((tab) => {
// Lấy URL của tab hiện tại
console.log("Current URL:", tab.url);
});

6
bid-extension/config.js Normal file
View File

@ -0,0 +1,6 @@
// config.js
const CONFIG = {
API_BASE_URL: "http://localhost:4000/api/v1",
};
export default CONFIG;

394
bid-extension/content.js Normal file
View File

@ -0,0 +1,394 @@
const CONFIG = {
API_BASE_URL: "http://localhost:4000/api/v1",
// API_BASE_URL: "https://bids.apactech.io/api/v1",
};
let PREV_DATA = null;
function removeFalsyValues(obj, excludeKeys = []) {
return Object.entries(obj).reduce((acc, [key, value]) => {
if (value || excludeKeys.includes(key)) {
acc[key] = value;
}
return acc;
}, {});
}
function extractDomain(url) {
try {
const parsedUrl = new URL(url);
return parsedUrl.origin;
} catch (error) {
return null;
}
}
function extractModelId(url) {
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;
}
}
}
const showPage = async (pageLink = "pages/popup/popup.html") => {
const res = await fetch(chrome.runtime.getURL(pageLink));
const html = await res.text();
const wrapper = document.createElement("div");
wrapper.innerHTML = html;
document.body.appendChild(wrapper);
};
const getKey = () => {
return new Promise((resolve, reject) => {
chrome.storage.local.get("key", (result) => {
if (chrome.runtime.lastError) {
reject(chrome.runtime.lastError);
} else {
resolve(result.key || null);
}
});
});
};
async function handleCreate(event, formElements) {
event.preventDefault();
const key = await getKey();
if (!key) {
showKey();
return;
}
const maxPrice = parseFloat(formElements.maxPrice.value);
const plusPrice = parseFloat(formElements.plusPrice.value);
const quantity = parseInt(formElements.quantity.value, 10);
const payload = {
url: formElements.url.value.trim(),
max_price: isNaN(maxPrice) ? null : maxPrice,
plus_price: isNaN(plusPrice) ? null : plusPrice,
quantity: isNaN(quantity) ? null : quantity,
};
// Validate required fields
if (!payload.url || payload.max_price === null) {
alert("Please fill out the URL and Max Price fields correctly.");
return;
}
try {
const response = await fetch(`${CONFIG.API_BASE_URL}/bids`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: key,
},
body: JSON.stringify(removeFalsyValues(payload)),
});
const result = await response.json();
alert(result.message);
// showInfo
await showInfo(extractModelId(payload.url), formElements);
// handleChangeTitleButton
handleChangeTitleButton(true, formElements);
} catch (error) {
alert("Error: " + error.message);
console.error("API Error:", error);
}
}
async function handleUpdate(event, formElements, id) {
event.preventDefault();
const key = await getKey();
if (!key) {
showKey();
return;
}
const maxPrice = parseFloat(formElements.maxPrice.value);
const plusPrice = parseFloat(formElements.plusPrice.value);
const quantity = parseInt(formElements.quantity.value, 10);
const payload = {
max_price: isNaN(maxPrice) ? null : maxPrice,
plus_price: isNaN(plusPrice) ? null : plusPrice,
quantity: isNaN(quantity) ? null : quantity,
};
// Validate required fields
if (payload.max_price === null) {
alert("Please fill out the URL and Max Price fields correctly.");
return;
}
try {
const response = await fetch(`${CONFIG.API_BASE_URL}/bids/info/${id}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
Authorization: key,
},
body: JSON.stringify(removeFalsyValues(payload)),
});
const result = await response.json();
alert(result.message);
} catch (error) {
alert("Error: " + error.message);
console.error("API Error:", error);
}
}
const showBid = () => {
const formKey = document.getElementById("form-key");
const formBid = document.getElementById("form-bid");
formKey.style.display = "none";
formBid.style.display = "block";
};
const showKey = async () => {
const key = await getKey();
const formKey = document.getElementById("form-key");
const formBid = document.getElementById("form-bid");
const keyEl = document.querySelector("#form-key #key");
formBid.style.display = "none";
formKey.style.display = "block";
if (key && keyEl) {
keyEl.value = key;
}
};
const handleToogle = async () => {
const btn = document.getElementById("toggle-bid-extension");
const panel = document.getElementById("bid-extension");
// Kiểm tra xem nút và panel có tồn tại hay không
if (btn && panel) {
btn.addEventListener("click", async () => {
panel.style.display = panel.style.display === "none" ? "block" : "none";
await handleShowForm();
});
} else {
console.error("Không tìm thấy nút hoặc panel!");
}
};
const handleShowForm = async () => {
const toggleBtn = document.getElementById("toggle-bid-extension");
const formBid = document.getElementById("form-bid");
const formKey = document.getElementById("form-key");
const keyBtn = document.getElementById("key-btn");
const isVisible = (el) => el && el.style.display !== "none";
// Toggle hiển thị form hiện tại (bid hoặc key)
toggleBtn?.addEventListener("click", async () => {
if (isVisible(formBid)) {
formBid.style.display = "none";
} else if (isVisible(formKey)) {
formKey.style.display = "none";
} else {
const currentKey = await getKey();
if (!currentKey) {
showKey();
} else {
showBid();
}
}
});
// Nhấn vào icon key để chuyển sang form-key
keyBtn?.addEventListener("click", () => {
showKey();
});
};
const handleChangeTitleButton = (result, formElements) => {
if (result) {
formElements.createBtn.textContent = "Update";
} else {
formElements.createBtn.textContent = "Create";
}
};
const handleSaveKey = () => {
const form = document.querySelector("#form-key form");
if (!form) return;
form.addEventListener("submit", async (e) => {
e.preventDefault();
const inputKey = form.querySelector("#key");
if (!inputKey) return;
const keyValue = inputKey.value.trim();
if (!keyValue) {
alert("Please enter a key");
return;
}
// Lưu vào chrome.storage.local
chrome.storage.local.set({ key: keyValue }, async () => {
alert("Key saved successfully!");
showBid();
if (!isValidModel()) return;
await showInfo();
});
});
};
const isValidModel = () => {
const currentUrl = window.location.href;
const model = extractModelId(currentUrl);
return !!model;
};
const createInfoColumn = (data, formElements) => {
const inputsContainer = document.querySelector("#bid-extension .inputs");
const urlCol = document.querySelector("#url-col");
if (!inputsContainer || !urlCol) return;
// 1. Thêm ID và Name vào đầu inputsContainer
const otherEls = `
<div class="col">
<label>ID</label>
<input readonly value="${data?.id || "None"}" type="text" id="id" />
</div>
<div class="col">
<label>Name</label>
<textarea readonly id="maxPrice">${data?.name || "None"}</textarea>
</div>
`;
inputsContainer.insertAdjacentHTML("afterbegin", otherEls);
// 2. Tạo và chèn Current Price ngay sau #url-col
const currentPriceDiv = document.createElement("div");
currentPriceDiv.className = "col";
currentPriceDiv.innerHTML = `
<label>Current price</label>
<input readonly type="text" value="${
data?.current_price || "None"
}" id="currentPrice" />
`;
urlCol.parentNode.insertBefore(currentPriceDiv, urlCol.nextSibling);
formElements.quantity.value = data?.quantity || 1;
formElements.plusPrice.value = data?.plus_price || 0;
};
const showInfo = async (model, formElements) => {
const key = await getKey();
if (!key) {
showKey();
return;
}
try {
const response = await fetch(`${CONFIG.API_BASE_URL}/bids/${model}`, {
method: "GET",
headers: {
"Content-Type": "application/json",
Authorization: key,
},
});
const result = await response.json();
if (!result || result?.status_code !== 200 || !result?.data) {
if (result.status_code !== 404) {
alert(result.message);
}
PREV_DATA = null;
return null;
}
formElements.maxPrice.value = result.data.max_price;
createInfoColumn(result.data, formElements);
PREV_DATA = result;
return result;
} catch (error) {
alert("Error: " + error.message);
console.error("API Error:", error);
}
};
(async () => {
await showPage();
const formElements = {
url: document.querySelector("#form-bid #url"),
maxPrice: document.querySelector("#form-bid #maxPrice"),
plusPrice: document.querySelector("#form-bid #plusPrice"),
quantity: document.querySelector("#form-bid #quantity"),
createBtn: document.querySelector("#form-bid #createBtn"),
form: document.querySelector("#form-bid form"),
};
const style = document.createElement("link");
style.rel = "stylesheet";
style.href = chrome.runtime.getURL("assets/css/index.css");
document.head.appendChild(style);
const script = document.createElement("script");
script.type = "module";
script.src = chrome.runtime.getURL("pages/popup/popup.js");
script.defer = true;
document.body.appendChild(script);
handleSaveKey();
const currentUrl = window.location.href;
const model = extractModelId(currentUrl);
if (!model) return;
// set url on form
formElements.url.value = currentUrl;
await showInfo(model, formElements);
handleChangeTitleButton(!!PREV_DATA, formElements);
formElements.form.addEventListener("submit", (e) =>
PREV_DATA
? handleUpdate(e, formElements, PREV_DATA.data.id)
: handleCreate(e, formElements)
);
})();

View File

@ -0,0 +1,42 @@
{
"manifest_version": 3,
"name": "Bid Extension",
"version": "1.0",
"description": "Bid Extension",
"action": {
"default_popup": "pages/popup/popup.html",
"default_icon": {
"16": "assets/icons/16.png",
"32": "assets/icons/32.png",
"128": "assets/icons/128.png"
}
},
"background": {
"service_worker": "background.js"
},
"permissions": ["storage"],
"host_permissions": ["http://*/*", "https://*/*"],
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["content.js"]
}
],
"web_accessible_resources": [
{
"resources": [
"pages/popup/popup.html",
"pages/popup/popup.js",
"assets/css/index.css",
"config.js",
"assets/icons/*"
],
"matches": ["<all_urls>"]
}
],
"icons": {
"16": "assets/icons/16.png",
"32": "assets/icons/32.png",
"128": "assets/icons/128.png"
}
}

View File

@ -0,0 +1,170 @@
<div
id="bid-toggle-container"
style="position: fixed; bottom: 20px; left: 20px; z-index: 9999"
>
<button
id="toggle-bid-extension"
style="
padding: 12px 20px;
background: #2c2f36;
color: #ffffff;
border: none;
border-radius: 9999px;
font-size: 15px;
font-weight: 500;
cursor: pointer;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3);
transition: background 0.3s ease, transform 0.2s ease;
"
onmouseover="this.style.background='#3a3d44'; this.style.transform='scale(1.05)'"
onmouseout="this.style.background='#2c2f36'; this.style.transform='scale(1)'"
>
<svg
fill="#ffffff"
version="1.1"
id="Capa_1"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
x="0px"
y="0px"
width="20px"
height="20px"
viewBox="0 0 494.212 494.212"
style="enable-background: new 0 0 494.212 494.212"
xml:space="preserve"
>
<g>
<path
d="M483.627,401.147L379.99,297.511c-7.416-7.043-16.084-10.567-25.981-10.567c-10.088,0-19.222,4.093-27.401,12.278
l-73.087-73.087l35.98-35.976c2.663-2.667,3.997-5.901,3.997-9.71c0-3.806-1.334-7.042-3.997-9.707
c0.377,0.381,1.52,1.569,3.423,3.571c1.902,2,3.142,3.188,3.72,3.571c0.571,0.378,1.663,1.328,3.278,2.853
c1.625,1.521,2.901,2.475,3.856,2.853c0.958,0.378,2.245,0.95,3.867,1.713c1.615,0.761,3.183,1.283,4.709,1.57
c1.522,0.284,3.237,0.428,5.14,0.428c7.228,0,13.703-2.665,19.411-7.995c0.574-0.571,2.286-2.14,5.14-4.712
c2.861-2.574,4.805-4.377,5.855-5.426c1.047-1.047,2.621-2.806,4.716-5.28c2.091-2.475,3.569-4.57,4.425-6.283
c0.853-1.711,1.708-3.806,2.57-6.28c0.855-2.474,1.279-4.949,1.279-7.423c0-7.614-2.665-14.087-7.994-19.417L236.41,8.003
c-5.33-5.33-11.802-7.994-19.413-7.994c-2.474,0-4.948,0.428-7.426,1.283c-2.475,0.854-4.567,1.713-6.28,2.568
c-1.714,0.855-3.806,2.331-6.28,4.427c-2.474,2.094-4.233,3.665-5.282,4.712c-1.047,1.049-2.855,3-5.424,5.852
c-2.572,2.856-4.143,4.57-4.712,5.142c-5.327,5.708-7.994,12.181-7.994,19.414c0,1.903,0.144,3.616,0.431,5.137
c0.288,1.525,0.809,3.094,1.571,4.714c0.76,1.618,1.331,2.903,1.713,3.853c0.378,0.95,1.328,2.24,2.852,3.858
c1.525,1.615,2.475,2.712,2.856,3.284c0.378,0.575,1.571,1.809,3.567,3.715c2,1.902,3.193,3.049,3.571,3.427
c-2.664-2.667-5.901-3.999-9.707-3.999s-7.043,1.331-9.707,3.999l-99.371,99.357c-2.667,2.666-3.999,5.901-3.999,9.707
c0,3.809,1.331,7.045,3.999,9.71c-0.381-0.381-1.524-1.574-3.427-3.571c-1.902-2-3.14-3.189-3.711-3.571
c-0.571-0.378-1.665-1.328-3.283-2.852c-1.619-1.521-2.905-2.474-3.855-2.853c-0.95-0.378-2.235-0.95-3.854-1.714
c-1.615-0.76-3.186-1.282-4.71-1.569c-1.521-0.284-3.234-0.428-5.137-0.428c-7.233,0-13.709,2.664-19.417,7.994
c-0.568,0.57-2.284,2.144-5.138,4.712c-2.856,2.572-4.803,4.377-5.852,5.426c-1.047,1.047-2.615,2.806-4.709,5.281
c-2.093,2.474-3.571,4.568-4.426,6.283c-0.856,1.709-1.709,3.806-2.568,6.28C0.432,212.061,0,214.535,0,217.01
c0,7.614,2.665,14.082,7.994,19.414l116.485,116.481c5.33,5.328,11.803,7.991,19.414,7.991c2.474,0,4.948-0.422,7.426-1.277
c2.475-0.855,4.567-1.714,6.28-2.569c1.713-0.855,3.806-2.327,6.28-4.425s4.233-3.665,5.28-4.716
c1.049-1.051,2.856-2.995,5.426-5.855c2.572-2.851,4.141-4.565,4.712-5.14c5.327-5.709,7.994-12.184,7.994-19.411
c0-1.902-0.144-3.617-0.431-5.14c-0.288-1.526-0.809-3.094-1.571-4.716c-0.76-1.615-1.331-2.902-1.713-3.854
c-0.378-0.951-1.328-2.238-2.852-3.86c-1.525-1.615-2.475-2.71-2.856-3.285c-0.38-0.571-1.571-1.807-3.567-3.717
c-2.002-1.902-3.193-3.045-3.571-3.429c2.663,2.669,5.902,4.001,9.707,4.001c3.806,0,7.043-1.332,9.707-4.001l35.976-35.974
l73.086,73.087c-8.186,8.186-12.278,17.312-12.278,27.401c0,10.283,3.621,18.843,10.849,25.7L401.42,483.643
c7.042,7.035,15.604,10.561,25.693,10.561c9.896,0,18.555-3.525,25.981-10.561l30.546-30.841
c7.043-7.043,10.571-15.605,10.571-25.693C494.212,417.231,490.684,408.566,483.627,401.147z"
/>
</g>
<g></g>
<g></g>
<g></g>
<g></g>
<g></g>
<g></g>
<g></g>
<g></g>
<g></g>
<g></g>
<g></g>
<g></g>
<g></g>
<g></g>
<g></g>
</svg>
</button>
</div>
<div
id="bid-extension"
class="wrapper"
style="
display: none;
position: fixed;
bottom: 90px;
left: 20px;
z-index: 9999;
background-color: #1e1e1e;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
padding: 20px;
width: 320px;
"
>
<!-- Form bid -->
<div style="display: none" id="form-bid">
<form class="container">
<h2>Bid</h2>
<div class="inputs">
<div id="url-col" class="col">
<label>Url</label>
<input readonly type="text" id="url" />
</div>
<div class="col">
<label>Max price</label>
<input type="number" id="maxPrice" />
</div>
<div class="col">
<label>Plus price</label>
<input type="number" id="plusPrice" />
</div>
<div class="col">
<label>Quantity</label>
<input type="number" id="quantity" />
</div>
</div>
<button type="submit" id="createBtn">Create</button>
</form>
<div class="key-container">
<span id="key-btn" class="key-btn">
<svg
fill="#ffffff"
height="14px"
width="14px"
viewBox="0 0 367.578 367.578"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M281.541,97.751c0-53.9-43.851-97.751-97.751-97.751S86.038,43.851,86.038,97.751
c0,44.799,30.294,82.652,71.472,94.159v144.668c0,4.026,1.977,9.1,4.701,12.065l14.514,15.798
c1.832,1.993,4.406,3.136,7.065,3.136s5.233-1.143,7.065-3.136l14.514-15.798
c2.724-2.965,4.701-8.039,4.701-12.065v-7.387l14.592-9.363
c2.564-1.646,4.035-4.164,4.035-6.909c0-2.744-1.471-5.262-4.036-6.907l-14.591-9.363v-0.207
l14.592-9.363c2.564-1.646,4.035-4.164,4.035-6.909c0-2.744-1.471-5.262-4.036-6.907l-14.591-9.363v-0.207
l14.592-9.363c2.564-1.646,4.035-4.164,4.035-6.908c0-2.745-1.471-5.263-4.036-6.909l-14.591-9.363V191.91
C251.246,180.403,281.541,142.551,281.541,97.751z
M183.789,104.948c-20.985,0-37.996-17.012-37.996-37.996s17.012-37.996,37.996-37.996
s37.996,17.012,37.996,37.996S204.774,104.948,183.789,104.948z"
/>
</svg>
</span>
</div>
</div>
<!-- Form key -->
<div style="display: block" id="form-key">
<form class="container">
<h2>Key</h2>
<div class="inputs">
<div class="col">
<label>Key</label>
<input type="password" id="key" />
</div>
</div>
<button type="submit" id="saveKeyBtn">Save</button>
</form>
</div>
</div>

View File

@ -0,0 +1,17 @@
const handleToogle = () => {
const btn = document.getElementById("toggle-bid-extension");
const panel = document.getElementById("bid-extension");
// Kiểm tra xem nút và panel có tồn tại hay không
if (btn && panel) {
btn.addEventListener("click", () => {
panel.style.display = panel.style.display === "none" ? "block" : "none";
});
} else {
console.error("Không tìm thấy nút hoặc panel!");
}
};
// init();
handleToogle();