update sync

This commit is contained in:
Admin 2025-08-14 13:14:30 +07:00
parent 80309efde2
commit 58dde4b304
25 changed files with 499 additions and 81 deletions

View File

@ -1 +0,0 @@
function n(e){return new Promise(t=>setTimeout(t,e))}export{n as d};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -6,11 +6,17 @@
"permissions": ["storage", "tabs", "alarms"],
"host_permissions": ["https://www.facebook.com/marketplace/*"],
"host_permissions": [
"https://www.facebook.com/marketplace/*",
"https://web.facebook.com/*"
],
"content_scripts": [
{
"matches": ["https://www.facebook.com/marketplace/*"],
"matches": [
"https://www.facebook.com/marketplace/*",
"https://web.facebook.com/marketplace/*"
],
"js": ["content.js"],
"run_at": "document_idle",
"type": "module"

View File

@ -6,11 +6,17 @@
"permissions": ["storage", "tabs", "alarms"],
"host_permissions": ["https://www.facebook.com/marketplace/*"],
"host_permissions": [
"https://www.facebook.com/marketplace/*",
"https://web.facebook.com/*"
],
"content_scripts": [
{
"matches": ["https://www.facebook.com/marketplace/*"],
"matches": [
"https://www.facebook.com/marketplace/*",
"https://web.facebook.com/marketplace/*"
],
"js": ["content.js"],
"run_at": "document_idle",
"type": "module"

View File

@ -1,27 +0,0 @@
import axios from "@/lib/axios";
class MessageApiService {
async sendSingleMessage(message: IMessage) {
try {
const { data } = await axios.post("/messages", message);
console.log("[NestJS] Response (single):", data);
return data;
} catch (err) {
console.error("[NestJS] Error (single):", err);
throw err;
}
}
async sendBulkMessages(messages: IMessage[]) {
try {
const { data } = await axios.post("/messages/bulk", { data: messages });
console.log("[NestJS] Response (bulk):", data);
return data;
} catch (err) {
console.error("[NestJS] Error (bulk):", err);
throw err;
}
}
}
export const messageApi = new MessageApiService();

View File

@ -0,0 +1,18 @@
import axios from "@/lib/axios";
class ProductApiService {
async sync(items: ISyncItem[]) {
try {
const { data } = await axios.post("/products/sync", {
items,
});
console.log("[NestJS] Response (bulk):", data);
return data;
} catch (err) {
console.error("[NestJS] Error (bulk):", err);
throw err;
}
}
}
export const productApi = new ProductApiService();

View File

@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import PQueue from "p-queue";
let ports: chrome.runtime.Port[] = [];
@ -10,22 +11,6 @@ chrome.runtime.onConnect.addListener((port) => {
});
});
(() => {
const evtSource = new EventSource(
`${import.meta.env.VITE_API_URL}/products/publist-stream`
);
evtSource.onmessage = (event) => {
const data = JSON.parse(event.data);
console.log("New event:", data);
queue.add(() => handlePublish(data, 30000));
};
evtSource.onerror = (err) => {
console.error("EventSource failed:", err);
};
})();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async function handlePublish(data: any, timeoutMs = 5 * 60 * 1000) {
return new Promise<void>((resolve) => {
@ -43,6 +28,7 @@ async function handlePublish(data: any, timeoutMs = 5 * 60 * 1000) {
chrome.tabs.onRemoved.removeListener(onTabClosed);
clearTimeout(timeoutId);
resolve();
ensureMarketplaceSellingTab();
};
const onConnectListener = (port: chrome.runtime.Port) => {
@ -71,9 +57,101 @@ async function handlePublish(data: any, timeoutMs = 5 * 60 * 1000) {
});
}
const handleListenPublists = () => {
const evtSource = new EventSource(
`${import.meta.env.VITE_API_URL}/products/publist-stream`
);
evtSource.onmessage = (event) => {
const data = JSON.parse(event.data);
console.log("New event:", data);
queue.add(() => handlePublish(data, 30000));
};
evtSource.onerror = (err) => {
console.error("EventSource failed:", err);
};
};
const handleListenDeletes = () => {
const evtSource = new EventSource(
`${import.meta.env.VITE_API_URL}/products/delete-stream`
);
evtSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
console.log("[SSE] New event:", data);
queue.add(() => {
return new Promise<void>((resolve) => {
const listener = (msg: any) => {
if (msg?.type === "delete-done") {
console.log("[QUEUE] Delete done signal received.");
chrome.runtime.onMessage.removeListener(listener);
resolve();
}
};
chrome.runtime.onMessage.addListener(listener);
// Gửi tới tab có URL khớp
chrome.tabs.query(
{ url: "https://www.facebook.com/marketplace/you/selling" },
(tabs) => {
for (const tab of tabs) {
if (tab.id) {
chrome.tabs.sendMessage(tab.id, {
type: "DELETE_STREAM_DATA",
payload: data,
});
}
}
}
);
});
});
} catch (err) {
console.error("[SSE] Parse error:", err);
}
};
evtSource.onerror = (err) => {
console.error("[SSE] Connection failed:", err);
};
console.log("[SSE] Listening for delete-stream events...");
};
function ensureMarketplaceSellingTab() {
const targetUrl = "https://www.facebook.com/marketplace/you/selling";
console.log({ targetUrl });
chrome.tabs.query({}, (tabs) => {
const existingTab = tabs.find(
(tab) => tab.url && tab.url.startsWith(targetUrl)
);
if (existingTab) {
// Nếu đã có thì refresh lại
chrome.tabs.reload((existingTab as any).id);
} else {
// Nếu chưa có thì mở mới
chrome.tabs.create({ url: targetUrl });
}
});
}
const init = async () => {
handleListenPublists();
handleListenDeletes();
ensureMarketplaceSellingTab();
};
chrome.runtime.onMessage.addListener((message, sender) => {
if (message.type === "close-tab") {
const tabId = sender.tab?.id;
if (tabId) chrome.tabs.remove(tabId);
}
});
init();

View File

@ -1,8 +1,9 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
// content.ts
import { productApi } from "./api/product-api.service";
import { delay } from "./features/app";
import axios from "./lib/axios";
import { thiefService } from "./services/thief.service";
const selectors = {
file__image_input: 'input[type="file"]',
title_input:
@ -58,23 +59,10 @@ const selectors = {
"/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[1]/div[1]/div[1]/div/div[5]/div/div/div",
publish_btn:
"/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[1]/div[1]/div[1]/div/div[4]/div[2]/div/div",
products:
"/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[1]/div[1]/div[2]/div/div/div[2]/div[1]/div/div[2]/div/div/span/div/div",
};
// const getData = async () => {
// return new Promise((rev, rej) => {
// const url = chrome.runtime.getURL("data.json");
// fetch(url)
// .then((res) => res.json())
// .then((data) => {
// rev(data as IItem[]);
// })
// .catch((err) => {
// rej(err);
// });
// });
// };
const uploadImages = async (item: IItem) => {
// Tạo DataTransfer để giả lập FileList
const dt: DataTransfer = new DataTransfer();
@ -214,6 +202,19 @@ const finistPublist = async (
return data;
};
const finistDelete = async (
item: IItem,
values: { error?: string; published: boolean }
) => {
const { data } = await axios({
url: "products/delete-finish/" + item.id,
method: "POST",
data: values,
});
return data;
};
const clickNext = async () => {
const btn = await thiefService.getElementByXPath(selectors.next_btn);
@ -230,6 +231,28 @@ const clickPublist = async () => {
thiefService.clickByPoint(btn);
};
function extractListings(productsEl: HTMLElement) {
const children = Array.from(productsEl.children);
return children.map((child) => {
// Lấy title (thường là span hoặc div có dir="auto")
const titleEl = child.querySelector('span[dir="auto"], div[dir="auto"]');
const title = titleEl?.textContent?.trim() || "";
// Lấy giá (span có attribute dir="auto")
const priceEl = Array.from(child.querySelectorAll('span[dir="auto"]')).find(
(el) =>
/\d/.test(el.textContent || "") && /[AU$]/.test(el.textContent || "")
);
// Tách lấy số, ví dụ: "AU$20" -> "20"
const priceMatch = priceEl?.textContent?.match(/\d+(?:\.\d+)?/);
const price = priceMatch ? parseFloat(priceMatch[0]) : 0;
return { title, price, el: productsEl };
});
}
/**
* B1. Upload images
* B2. Write title
@ -308,6 +331,31 @@ const handle = async (item: IItem) => {
return true;
};
const getProducts = async () => {
const products = await thiefService.getElementByXPath(selectors.products);
if (!products) return [];
return extractListings(products) as ISyncItem[];
};
const syncListing = async () => {
const url = window.location.href;
if (!url.includes("https://www.facebook.com/marketplace/you/selling")) return;
const products = await getProducts();
if (!products.length) return;
const response = await productApi.sync(
products.map((item) => ({
title: item.title,
price: item.price,
})) as ISyncItem[]
);
console.log({ response });
};
const closeTab = async (data: IItem) => {
chrome.runtime.sendMessage({
type: "close-tab",
@ -315,6 +363,64 @@ const closeTab = async (data: IItem) => {
});
};
const handleDelete = async (payload: IItem) => {
const products = await getProducts();
const product = products.find((product) => {
return product.title == payload.title && product.price == payload.price;
});
console.log({ payload, product, products });
if (!product) return;
const el = product.el;
const optionEl = el.querySelector(
`[aria-label="More options for ${product.title}"]`
);
console.log({ optionEl });
if (!optionEl) return;
(optionEl as any).click?.();
await delay(2000);
const items = Array.from(document.querySelectorAll('[role="menuitem"]'));
console.log({ items });
const deleteItem = items.find((item) =>
item.textContent.toLocaleLowerCase().includes("delete")
);
(deleteItem as any).click?.();
await delay(1000);
const btnDelete = await thiefService.getElementByXPath(
"/html/body/div[1]/div/div[1]/div/div[4]/div/div/div[1]/div/div[2]/div/div/div/div/div/div/div[3]/div/div/div/div/div[1]/div",
{
xpathFallback:
"/html/body/div[1]/div/div[1]/div/div[4]/div/div/div[1]/div/div[2]/div/div/div/div[3]/div[2]/div/div[2]/div[1]",
}
);
console.log({ btnDelete });
btnDelete?.click();
const closeModal = await thiefService.getElementByXPath(
"/html/body/div[1]/div/div[1]/div/div[4]/div/div/div[1]/div/div[2]/div/div/div/div[2]/div"
);
closeModal?.click();
await finistDelete(payload, { published: false });
chrome.runtime.sendMessage({ type: "delete-done" });
};
const port = chrome.runtime.connect();
port.onMessage.addListener(async (message) => {
@ -345,14 +451,25 @@ port.onMessage.addListener(async (message) => {
}
});
// content.js
chrome.runtime.onMessage.addListener((message) => {
if (message.type === "DELETE_STREAM_DATA") {
console.log("Nhận dữ liệu từ background:", message.payload);
handleDelete(message.payload);
}
});
async function init() {
const { data } = await axios.get("products/27");
// const { data } = await axios.get("products/33");
if (!data.data) return;
// if (!data.data) return;
const item = data.data as IItem;
// const item = data.data as IItem;
await handle(item);
// await handle(item);
await syncListing();
}
init();

View File

@ -15,3 +15,8 @@ export function removeFalsyValues<T extends Record<string, any>>(
export function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export function randomDelay(minMs: number, maxMs: number): Promise<void> {
const time = Math.floor(Math.random() * (maxMs - minMs + 1)) + minMs;
return delay(time);
}

View File

@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
interface IItem {
images: string[];
title: string;
@ -11,3 +12,9 @@ interface IItem {
location?: string;
id: number;
}
interface ISyncItem {
title: string;
price: number;
el: HTMLElement;
}

View File

@ -80,6 +80,18 @@ export function HistoryModal({ data, open, setOpen }: HistoryModalProps) {
</div>
)}
{item.message && (
<div className=" border border-gray-200 rounded-md p-3">
<div className="flex items-start gap-2">
<AlertCircle className="h-4 w-4 mt-0.5 flex-shrink-0" />
<div>
<p className="text-sm font-medium ">Message:</p>
<p className="text-sm mt-1">{item.message}</p>
</div>
</div>
</div>
)}
<div className="flex justify-between text-sm text-muted-foreground">
<span></span>
<span>

View File

@ -150,12 +150,12 @@ export default function List() {
},
{
key: "histories",
label: "Published",
label: "Status",
displayType: "custom",
render(value, row) {
return (
<div className="w-full flex items-center justify-center">
{row.histories.some((item) => item.published) ? (
{row.status ? (
<Button size={"xs"} className="w-fit" variant={"ghost"}>
<Check size={14} className="text-green-500" />
</Button>
@ -267,6 +267,7 @@ export default function List() {
},
onSuccess: (data) => {
console.log({ data });
refetch();
},
});

View File

@ -80,10 +80,12 @@ interface IProduct extends ITimestamp {
location: string;
id: number;
histories: IHistory[];
status: boolean;
}
interface IHistory extends ITimestamp {
id: number;
error?: string;
published: boolean;
message?: string;
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 527 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

View File

@ -57,6 +57,9 @@ export class Product extends CoreEntity {
@Column({ type: 'varchar', nullable: true })
location?: string;
@Column({ type: 'bool', default: false })
status: boolean;
@OneToMany(() => PublistHistory, (publistHistory) => publistHistory.product)
histories: PublistHistory[];
}

View File

@ -7,6 +7,9 @@ export class PublistHistory extends CoreEntity {
@Column({ type: 'varchar', nullable: true, default: null })
error: string;
@Column({ type: 'varchar', nullable: true, default: null })
message: string;
@Column({ type: 'bool' })
published: boolean;

View File

@ -0,0 +1,9 @@
import { IsNumber, IsString } from 'class-validator';
export class SyncItemDto {
@IsString()
title: string;
@IsNumber()
price: number;
}

View File

@ -0,0 +1,10 @@
import { IsArray, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
import { SyncItemDto } from './sync-item.dto';
export class SyncDto {
@IsArray()
@ValidateNested({ each: true })
@Type(() => SyncItemDto)
items: SyncItemDto[];
}

View File

@ -4,6 +4,7 @@ import { Body, Controller, Param, Post, Sse } from '@nestjs/common';
import { ProductsService } from './products.service';
import { CreateProductDto } from './dtos/create-product.dto';
import { PublistFinishDto } from './dtos/publist-finish.dto';
import { SyncDto } from './dtos/syncs.dto';
@Controller('products')
export class ProductsController extends CoreController<
@ -15,10 +16,15 @@ export class ProductsController extends CoreController<
}
@Post('')
async create(data: CreateProductDto): Promise<any> {
async create(@Body() data: CreateProductDto): Promise<any> {
return this.service.create(data);
}
@Post('sync')
async sync(@Body() data: SyncDto): Promise<any> {
return this.service.sync(data);
}
@Post('publist/:id')
async publist(@Param('id') id: Product['id']): Promise<any> {
return this.service.publist(id);
@ -31,9 +37,21 @@ export class ProductsController extends CoreController<
): Promise<any> {
return this.service.publistFinish(id, data);
}
@Post('delete-finish/:id')
async deleteFinish(
@Param('id') id: Product['id'],
@Body() data: PublistFinishDto,
): Promise<any> {
return this.service.deleteFinish(id, data);
}
@Sse('publist-stream')
async publistStream() {
return this.service.sendEvents();
return this.service.sendPublistEvents();
}
@Sse('delete-stream')
async deleteStream() {
return this.service.sendDeleteEvents();
}
}

View File

@ -19,12 +19,15 @@ import { MediasService } from '../medias/medias.service';
import { CreateProductDto } from './dtos/create-product.dto';
import { PublistFinishDto } from './dtos/publist-finish.dto';
import { Request } from 'express';
import { SyncDto } from './dtos/syncs.dto';
@Injectable()
export class ProductsService extends CoreService<Product> {
public static EVENTS = {
SEND_PUBLIST: 'send-publist',
SEND_DELETE: 'send-delete',
PUBLIST_FINISH: 'publist-finish',
DElETE_FINISH: 'delete-finish',
};
constructor(
@ -60,7 +63,7 @@ export class ProductsService extends CoreService<Product> {
data: Partial<Product>,
req: Request,
): Promise<void> {
// Xóa avatar nếu có
// Xóa images nếu có
if (data.images?.length) {
for (const image of data.images) {
await this.mediasService.removeMediasFolder(
@ -71,6 +74,29 @@ export class ProductsService extends CoreService<Product> {
}
}
protected async beforeDelete(
id: number,
data: Partial<Product>,
req: Request,
): Promise<boolean> {
// Emit sự kiện để bắt đầu publish
this.eventService.sendEvent(ProductsService.EVENTS.SEND_DELETE, {
...data,
});
// Đợi phản hồi từ client
const result = await this.waitForDeleteResult({ ...data, id });
if (!result)
throw new BadRequestException(
AppResponse.toResponse(false, {
message: SystemLang.getText('messages', 'try_again'),
}),
);
return true;
}
async create(data: CreateProductDto): Promise<any> {
const product = this.repo.create(data);
@ -187,6 +213,23 @@ export class ProductsService extends CoreService<Product> {
}
}
private async waitForDeleteResult(
data: Partial<Product>,
): Promise<PublistHistory> {
try {
return await this.eventService.waitForEvent<PublistHistory>(
`${ProductsService.EVENTS.DElETE_FINISH}_${data.id}`,
30000,
);
} catch {
throw new BadRequestException(
AppResponse.toResponse(null, {
message: SystemLang.getText('messages', 'try_again'),
}),
);
}
}
async publistFinish(id: Product['id'], data: PublistFinishDto) {
const product = await this.repo.findOne({ where: { id } });
@ -209,7 +252,29 @@ export class ProductsService extends CoreService<Product> {
return AppResponse.toResponse(plainData);
}
sendEvents(): Observable<{ data: any }> {
async deleteFinish(id: Product['id'], data: PublistFinishDto) {
const product = await this.repo.findOne({ where: { id } });
if (!product)
throw new NotFoundException(
AppResponse.toResponse(null, {
message: SystemLang.getText('messages', 'not_found'),
}),
);
const plainData = plainToClass(Product, product);
const result = await this.historiesRepo.save({ ...data, product });
this.eventService.sendEvent(
`${ProductsService.EVENTS.DElETE_FINISH}_${plainData.id}`,
plainToClass(PublistHistory, result),
);
return AppResponse.toResponse(plainData);
}
sendPublistEvents(): Observable<{ data: any }> {
// Tạo Observable dựa trên sự kiện 'send publist' của eventEmitter
return fromEvent(
this.eventService.event,
@ -220,4 +285,89 @@ export class ProductsService extends CoreService<Product> {
})),
);
}
sendDeleteEvents(): Observable<{ data: any }> {
// Tạo Observable dựa trên sự kiện 'send publist' của eventEmitter
return fromEvent(
this.eventService.event,
ProductsService.EVENTS.SEND_DELETE,
).pipe(
map((data) => ({
data, // gửi data về client
})),
);
}
async sync({ items }: SyncDto) {
const incomingTitles = new Set(items.map((d) => d.title));
// Lấy danh sách sản phẩm hiện tại trong DB
const products = await this.repo.find({
select: ['id', 'title', 'status', 'price'],
});
const toDisable: Product[] = [];
const toEnable: { product: Product; price: number }[] = [];
const toInsert: { title: string; price: number }[] = [];
const existingMap = new Map(products.map((p) => [p.title, p]));
// Tìm sản phẩm cần disable
for (const product of products) {
if (!incomingTitles.has(product.title) && product.status) {
toDisable.push(product);
}
}
// Tìm sản phẩm cần enable hoặc insert mới
for (const item of items) {
const existing = existingMap.get(item.title);
if (!existing) {
toInsert.push(item);
} else if (!existing.status || existing.price !== item.price) {
toEnable.push({ product: existing, price: item.price });
}
}
// Batch update disable
if (toDisable.length) {
await this.repo
.createQueryBuilder()
.update(Product)
.set({ status: false })
.whereInIds(toDisable.map((p) => p.id))
.execute();
await this.historiesRepo.save(
toDisable.map((product) => ({
message: `Product was disabled at: ${new Date().toISOString()}`,
product,
published: false,
})),
);
}
// Update enable + update giá + log
for (const { product, price } of toEnable) {
await this.repo.update(product.id, { status: true, price });
}
// Insert sản phẩm mới + log
if (toInsert.length) {
const newProducts = this.repo.create(
toInsert.map((item) => ({
title: item.title,
price: item.price,
status: true,
})),
);
await this.repo.save(newProducts);
}
return {
disabled: toDisable.length,
enabled: toEnable.length,
inserted: toInsert.length,
};
}
}