Update UI

This commit is contained in:
johnathan 2025-04-28 17:59:32 +08:00
parent 063aca6b94
commit 80f5fedd0d
12 changed files with 4424 additions and 1546 deletions

7
.vscode/launch.json vendored
View File

@ -11,6 +11,13 @@
"webRoot": "${workspaceFolder}/src/",
"port": 9222,
"urlFilter": "chrome-extension://fmalpbpehdilmjhnanhpjmnkgbahopfj/*"
},
{
"type": "msedge",
"request": "attach",
"name": "Attach to Amazon",
"port": 9222,
"urlFilter": "https://www.amazon.com/*"
}
]
}

View File

@ -65,5 +65,8 @@
},
"lint-staged": {
"**/*": "prettier --write --ignore-unknown"
},
"dependencies": {
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz"
}
}

5379
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

38
src/logic/data-io.ts Normal file
View File

@ -0,0 +1,38 @@
import { utils, writeFileXLSX } from 'xlsx';
/**
* XLSX文件
* @param data
* @param options
*/
export function exportToXLSX(
data: Record<string, unknown>[],
options: { fileName?: string; headers?: { label: string; prop: string }[]; index?: boolean } = {},
): void {
if (!data.length) {
return;
}
const headers = options.headers || Object.keys(data[0]).map((k) => ({ label: k, prop: k }));
if (options.index) {
headers.unshift({ label: 'Index', prop: 'Index' });
data.forEach((item, index) => {
item.Index = index + 1;
});
}
const rows = data.map((item) => {
const row: Record<string, unknown> = {};
headers.forEach((header) => {
row[header.label] = item[header.prop];
});
return row;
});
const worksheet = utils.json_to_sheet(rows, {
header: headers.map((h) => h.label),
});
const workbook = utils.book_new();
utils.book_append_sheet(workbook, worksheet, 'Sheet1');
const fileName = options.fileName || `export_${new Date().toISOString().slice(0, 10)}.xlsx`;
writeFileXLSX(workbook, fileName, { bookType: 'xlsx', type: 'binary', compression: true });
}

View File

@ -1,5 +1,5 @@
import Emittery from 'emittery';
import type { AmazonGoodsLinkItem, AmazonPageWorker, AmazonPageWorkerEvents } from './types';
import type { AmazonPageWorker, AmazonPageWorkerEvents } from './types';
import type { Tabs } from 'webextension-polyfill';
import { exec } from '../execute-script';
@ -56,9 +56,9 @@ class AmazonPageWorkerImpl implements AmazonPageWorker {
const pagePattern = await exec(tabId, async () => {
return [
...(document.querySelectorAll<HTMLDivElement>(
'.a-section.a-spacing-small.puis-padding-left-small',
'.puisg-row:has(.a-section.a-spacing-small.a-spacing-top-small:not(.a-text-right))',
) as unknown as HTMLDivElement[]),
].filter((e) => e.getClientRects().length > 0).length === 0
].filter((e) => e.getClientRects().length > 0).length > 0
? 'pattern-1'
: 'pattern-2';
});
@ -68,24 +68,28 @@ class AmazonPageWorkerImpl implements AmazonPageWorker {
}
// #endregion
// #region Retrieve key nodes and their information from the critical product search page
let data: AmazonGoodsLinkItem[] | null = null;
let data: { link: string; title: string; imageSrc: string }[] | null = null;
switch (pagePattern) {
// 处理商品以列表形式展示的情况
case 'pattern-1':
data = await exec(tabId, async () => {
const items = [
...(document.querySelectorAll<HTMLDivElement>(
'.a-section.a-spacing-small.a-spacing-top-small:not(.a-text-right)',
'.puisg-row:has(.a-section.a-spacing-small.a-spacing-top-small:not(.a-text-right))',
) as unknown as HTMLDivElement[]),
].filter((e) => e.getClientRects().length > 0);
const linkObjs = items.reduce<AmazonGoodsLinkItem[]>((objs, el) => {
const link = el.querySelector<HTMLAnchorElement>('a')?.href;
const title = el
.querySelector<HTMLHeadingElement>('h2.a-color-base')
?.getAttribute('aria-label');
link && objs.push({ link, title: title || '' });
return objs;
}, []);
const linkObjs = items.reduce<{ link: string; title: string; imageSrc: string }[]>(
(objs, el) => {
const link = el.querySelector<HTMLAnchorElement>('a')?.href;
const title = el
.querySelector<HTMLHeadingElement>('h2.a-color-base')!
.getAttribute('aria-label')!;
const imageSrc = el.querySelector<HTMLImageElement>('img.s-image')!.src!;
link && objs.push({ link, title, imageSrc });
return objs;
},
[],
);
return linkObjs;
});
break;
@ -94,15 +98,19 @@ class AmazonPageWorkerImpl implements AmazonPageWorker {
data = await exec(tabId, async () => {
const items = [
...(document.querySelectorAll<HTMLDivElement>(
'.a-section.a-spacing-small.puis-padding-left-small',
'.puis-card-container:has(.a-section.a-spacing-small.puis-padding-left-small)',
) as unknown as HTMLDivElement[]),
].filter((e) => e.getClientRects().length > 0);
const linkObjs = items.reduce<AmazonGoodsLinkItem[]>((objs, el) => {
const link = el.querySelector<HTMLAnchorElement>('a.a-link-normal')?.href;
const title = el.querySelector<HTMLHeadingElement>('h2.a-color-base')?.innerText;
link && objs.push({ link, title: title || '' });
return objs;
}, []);
const linkObjs = items.reduce<{ link: string; title: string; imageSrc: string }[]>(
(objs, el) => {
const link = el.querySelector<HTMLAnchorElement>('a.a-link-normal')?.href;
const title = el.querySelector<HTMLHeadingElement>('h2.a-color-base')!.innerText;
const imageSrc = el.querySelector<HTMLImageElement>('img.s-image')!.src!;
link && objs.push({ link, title, imageSrc });
return objs;
},
[],
);
return linkObjs;
});
break;
@ -141,21 +149,36 @@ class AmazonPageWorkerImpl implements AmazonPageWorker {
stopSignal = true;
};
this.channel.on('error', stop);
let result = { hasNextPage: true, data: [] as AmazonGoodsLinkItem[] };
let result = {
hasNextPage: true,
data: [] as unknown[],
};
while (result.hasNextPage && !stopSignal) {
result = await this.wanderSearchSinglePage(tab);
this.channel.emit('item-links-collected', { objs: result.data });
this.channel.emit('item-links-collected', {
objs: result.data as { link: string; title: string; imageSrc: string }[],
});
}
this.channel.off('error', stop);
return new Promise((resolve) => setTimeout(resolve, 1000));
}
public async wanderDetailPage(asin: string): Promise<void> {
public async wanderDetailPage(entry: string): Promise<void> {
const tab = await this.getCurrentTab();
if (!tab.url?.includes(`/dp/${asin}`)) {
const params = { asin: '', url: '' };
if (entry.match(/^https?:\/\/www\.amazon\.com.*\/dp\/[A-Z0-9]{10}/)) {
const [asin] = /\/\/dp\/[A-Z0-9]{10}/.exec(entry)!;
params.asin = asin;
params.url = entry;
} else if (entry.match(/^[A-Z0-9]{10}$/)) {
params.asin = entry;
params.url = `https://www.amazon.com/dp/${entry}`;
}
if (!tab.url?.includes(`www.amazon.com`) || !tab.url?.includes(`/dp/${params.asin}`)) {
await browser.tabs.update(tab.id!, {
url: `https://www.amazon.com/dp/${asin}?th=1`,
url: params.url,
});
await new Promise<void>((resolve) => setTimeout(resolve, 1000));
}
//#region Await Production Introduction Element Loaded and Determine Page Pattern
const pattern = await exec(tab.id!, async () => {
@ -188,7 +211,7 @@ class AmazonPageWorkerImpl implements AmazonPageWorker {
});
if (ratingInfo && (ratingInfo.rating !== 0 || ratingInfo.ratingCount !== 0)) {
this.channel.emit('item-rating-collected', {
asin,
asin: params.asin,
...ratingInfo,
});
}
@ -230,7 +253,7 @@ class AmazonPageWorkerImpl implements AmazonPageWorker {
const category2Ranking = Number(/(?<=#)\d+/.exec(category2Statement)?.[0]) || null;
const category2Name = /(?<=in\s).+/.exec(category2Statement)?.[0] || null;
this.channel.emit('item-category-rank-collected', {
asin,
asin: params.asin,
category1: ![category1Name, category1Ranking].includes(null)
? { name: category1Name!, rank: category1Ranking! }
: undefined,
@ -253,7 +276,7 @@ class AmazonPageWorkerImpl implements AmazonPageWorker {
});
imageUrls &&
this.channel.emit('item-images-collected', {
asin,
asin: entry,
urls: imageUrls,
});
//#endregion

View File

@ -1,12 +1,18 @@
import type Emittery from 'emittery';
type AmazonGoodsLinkItem = { link: string; title: string };
type AmazonGoodsLinkItem = {
link: string;
title: string;
asin: string;
rank: number;
imageSrc: string;
};
interface AmazonPageWorkerEvents {
/**
* The event is fired when worker collected links to items on the Amazon search page.
*/
['item-links-collected']: { objs: AmazonGoodsLinkItem[] };
['item-links-collected']: { objs: { link: string; title: string; imageSrc: string }[] };
/**
* The event is fired when worker collected goods' rating on the Amazon detail page.
@ -61,7 +67,7 @@ interface AmazonPageWorker {
/**
* Browsing goods detail page and collect target information.
* @param asin Product indentification
* @param entry Product link or Amazon Standard Identification Number.
*/
wanderDetailPage(asin: string): Promise<void>;
wanderDetailPage(entry: string): Promise<void>;
}

View File

@ -1,3 +1,6 @@
import { useWebExtensionStorage } from '~/composables/useWebExtensionStorage';
import type { AmazonGoodsLinkItem } from './page-worker/types';
export const keywords = useWebExtensionStorage<string>('keywords', '');
export const keywords = useWebExtensionStorage<string>('keywords', '');
export const itemList = useWebExtensionStorage<AmazonGoodsLinkItem[]>('itemList', []);

View File

@ -1,13 +1,13 @@
<script lang="ts" setup>
import SidePanel from './SidePanel.vue';
import TestPanel from './TestPanel.vue';
</script>
<template>
<!-- Naive UI Wrapper-->
<n-modal-provider>
<n-dialog-provider>
<n-message-provider>
<test-panel />
<side-panel />
</n-message-provider>
</n-dialog-provider>
</n-modal-provider>

View File

@ -0,0 +1,49 @@
<script setup lang="ts">
import pageWorkerFactory from '~/logic/page-worker';
const inputText = ref('');
const data = ref<Record<string, unknown>>({});
const worker = pageWorkerFactory.useAmazonPageWorker();
onMounted(() => {
worker.channel.on('item-rating-collected', (ev) => {
console.log('item-rating-collected', ev);
data.value = { ...data.value, ...ev };
});
worker.channel.on('item-category-rank-collected', (ev) => {
console.log('item-category-rank-collected', ev);
data.value = { ...data.value, ...ev };
});
worker.channel.on('item-images-collected', (ev) => {
console.log('item-images-collected', ev);
data.value = { ...data.value, ...ev };
});
});
const handleGetInfo = async () => {
worker.wanderDetailPage(inputText.value);
};
</script>
<template>
<div class="detail-page-worker">
<n-form>
<n-form-item>
<n-input v-model:value="inputText" />
</n-form-item>
</n-form>
<n-button @click="handleGetInfo">Get Info</n-button>
<div>{{ data }}</div>
</div>
</template>
<style scoped lang="scss">
.detail-page-worker {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
background-color: #f0f0f0;
}
</style>

View File

@ -0,0 +1,212 @@
<script setup lang="ts">
import { keywords } from '~/logic/storage';
import pageWorker from '~/logic/page-worker';
import { exportToXLSX } from '~/logic/data-io';
import type { AmazonGoodsLinkItem } from '~/logic/page-worker/types';
import { NButton } from 'naive-ui';
import { itemList as items } from '~/logic/storage';
import type { TableColumn } from 'naive-ui/es/data-table/src/interface';
const message = useMessage();
const worker = pageWorker.useAmazonPageWorker();
const page = reactive({ current: 1, size: 5 });
const resultSearchText = ref('');
const columns: (TableColumn<AmazonGoodsLinkItem> & { hidden?: boolean })[] = [
{
title: '排位',
key: 'rank',
},
{
title: '标题',
key: 'title',
},
{
title: 'ASIN',
key: 'asin',
},
{
title: '图片',
key: 'imageSrc',
hidden: true,
},
{
title: '链接',
key: 'link',
render(row) {
return h(
NButton,
{
type: 'primary',
text: true,
size: 'small',
onClick: async () => {
const tab = await browser.tabs
.query({
active: true,
currentWindow: true,
})
.then((tabs) => tabs[0]);
if (tab) {
await browser.tabs.update(tab.id, {
url: row.link,
});
}
},
},
() => '前往',
);
},
},
];
const itemView = computed(() => {
const { current, size } = page;
const searchText = resultSearchText.value;
let data = items.value;
if (searchText.trim() !== '') {
data = data.filter(
(r) =>
r.title.toLowerCase().includes(searchText.toLowerCase()) ||
r.asin.toLowerCase().includes(searchText.toLowerCase()),
);
}
let pageCount = ~~(data.length / size);
pageCount += data.length % size > 0 ? 1 : 0;
data = data.slice((current - 1) * size, current * size);
return { data, pageCount };
});
const onItemLinksCollected = (ev: { objs: Record<string, unknown>[] }) => {
const addedRows = ev.objs.map((v, i) => {
const [asin] = /(?<=\/dp\/)[A-Z0-9]{10}/.exec(v.link as string)!;
return { ...v, asin, rank: items.value.length + i + 1 } as AmazonGoodsLinkItem;
});
items.value = items.value.concat(addedRows);
};
const onCollectStart = async () => {
if (keywords.value.trim() === '') {
return;
}
message.info('开始收集');
items.value = [];
worker.channel.on('error', ({ message: msg }) => {
message.error(msg);
});
worker.channel.on('item-links-collected', onItemLinksCollected);
await worker.doSearch(keywords.value);
await worker.wanderSearchPage();
worker.channel.off('item-links-collected', onItemLinksCollected);
message.info('完成');
};
const handleExport = async () => {
const headers = columns.reduce(
(p, v: Record<string, any>) => {
if ('key' in v && 'title' in v) {
p.push({ label: v.title, prop: v.key });
}
return p;
},
[] as { label: string; prop: string }[],
);
exportToXLSX(items.value, { headers });
message.info('导出完成');
};
</script>
<template>
<main class="search-page-worker">
<n-space class="app-header">
<mdi-cat style="font-size: 60px; color: black" />
<h1>Azon Seeker</h1>
</n-space>
<n-space>
<n-input
v-model:value="keywords"
class="search-input-box"
autosize
size="large"
round
placeholder="请输入关键词采集信息"
>
<template #prefix>
<n-icon size="20">
<ion-search />
</n-icon>
</template>
</n-input>
<n-button type="primary" round size="large" @click="onCollectStart">采集</n-button>
</n-space>
<div style="height: 10px"></div>
<n-card class="result-content-container" title="结果框">
<template #header-extra>
<n-space>
<n-input
v-model:value="resultSearchText"
size="small"
placeholder="输入关键词查询结果"
round
/>
<n-button type="primary" tertiary round size="small" @click="items = []">
<template #icon>
<ion-trash-outline />
</template>
</n-button>
<n-button type="primary" tertiary round size="small" @click="handleExport">
<template #icon>
<ion-download-outline />
</template>
</n-button>
</n-space>
</template>
<n-empty v-if="items.length === 0" size="huge" style="padding-top: 40px">
<template #icon>
<n-icon size="60">
<solar-cat-linear />
</n-icon>
</template>
<template #default>
<h3>还没有数据哦</h3>
</template>
</n-empty>
<n-space vertical v-else>
<n-data-table
:columns="columns.filter((col) => col.hidden !== true)"
:data="itemView.data"
/>
<n-pagination
v-model:page="page.current"
v-model:page-size="page.size"
:page-count="itemView.pageCount"
:page-sizes="[5, 10, 20]"
show-size-picker
/>
</n-space>
</n-card>
</main>
</template>
<style lang="scss" scoped>
.search-page-worker {
width: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 20px;
.app-header {
margin-top: 100px;
}
.search-input-box {
min-width: 270px;
}
.result-content-container {
width: 90%;
}
}
</style>

View File

@ -1,141 +1,89 @@
<script setup lang="ts">
import { keywords } from '~/logic/storage';
import pageWorker from '~/logic/page-worker';
import type { AmazonGoodsLinkItem } from '~/logic/page-worker/types';
import { NButton, type DataTableColumns } from 'naive-ui';
import DetailPageWorker from './DetailPageWorker.vue';
import SearchPageWorker from './SearchPageWorker.vue';
const message = useMessage();
const worker = pageWorker.useAmazonPageWorker();
type TableData = AmazonGoodsLinkItem & { rank: number };
const items = ref<AmazonGoodsLinkItem[]>([]);
const page = reactive({ current: 1, size: 5 });
const columns: DataTableColumns<TableData> = [
const tabs = [
{
title: '排位',
key: 'rank',
name: 'Search Page',
component: SearchPageWorker,
},
{
title: '标题',
key: 'title',
},
{
title: '链接',
key: 'link',
render(row) {
return h(
NButton,
{
type: 'primary',
text: true,
size: 'small',
onClick: async () => {
const tab = await browser.tabs
.query({
active: true,
currentWindow: true,
})
.then((tabs) => tabs[0]);
if (tab) {
await browser.tabs.update(tab.id, {
url: row.link,
});
}
},
},
() => '前往',
);
},
name: 'Detail Page',
component: DetailPageWorker,
},
];
const itemView = computed(() => {
const { current, size } = page;
return items.value
.slice((current - 1) * size, current * size)
.map((v, i) => ({ ...v, rank: 1 + (current - 1) * size + i }));
const selectedTab = ref(tabs[0].name);
const currentComponent = computed(() => {
const tab = tabs.find((tab) => tab.name === selectedTab.value);
return tab ? tab.component : null;
});
onMounted(() => {
worker.channel.on('item-links-collected', (ev) => {
items.value = items.value.concat(ev.objs);
});
});
const onCollect = async () => {
if (keywords.value.trim() === '') {
return;
}
message.info('开始收集');
worker.channel.on('error', ({ message: msg }) => {
message.error(msg);
});
await worker.doSearch(keywords.value);
await worker.wanderSearchPage();
message.info('完成');
};
const showHeader = ref(false);
</script>
<template>
<main class="side-panel">
<n-space class="app-header">
<mdi-cat style="font-size: 60px; color: black" />
<h1>Azon Seeker</h1>
</n-space>
<n-space>
<n-input
v-model:value="keywords"
class="search-input-box"
autosize
size="large"
round
placeholder="请输入关键词"
/>
<n-button type="primary" round size="large" @click="onCollect">采集</n-button>
</n-space>
<div style="height: 10px"></div>
<n-card class="result-content-container" title="结果框">
<n-empty v-if="items.length === 0" description="还没有结果哦">
<template #icon>
<n-icon :size="50">
<solar-cat-linear />
</n-icon>
</template>
</n-empty>
<n-space vertical v-else>
<n-data-table :columns="columns" :data="itemView" />
<n-pagination
v-model:page="page.current"
v-model:page-size="page.size"
:page-count="~~(items.length / page.size) + 1"
:page-sizes="[5, 10, 20]"
show-size-picker
/>
</n-space>
</n-card>
</main>
<div class="side-panel">
<div class="header-menu" v-if="showHeader">
<n-tabs placement="top" :default-value="tabs[0].name" type="card" v-model:value="selectedTab">
<n-tab v-for="tab in tabs" :name="tab.name" />
</n-tabs>
</div>
<div class="display-header-button" @click="showHeader = !showHeader">
<n-icon size="22">
<ion-chevron-up v-if="showHeader" />
<ion-chevron-down v-else />
</n-icon>
</div>
<div class="main-content">
<keep-alive>
<Component :is="currentComponent" />
</keep-alive>
</div>
</div>
</template>
<style lang="scss" scoped>
<style scoped lang="scss">
.side-panel {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 20px;
background-color: #f0f0f0;
.app-header {
margin-top: 100px;
.header-menu {
width: 100%;
background-color: #fff;
box-shadow: 0px 2px 4px rgba(0, 0, 0, 0.1);
border-bottom: 1px solid #eaeaea;
}
.search-input-box {
min-width: 270px;
.display-header-button {
width: 100%;
display: flex;
justify-content: center;
align-items: center;
background-color: #fff;
cursor: pointer;
> .n-icon {
opacity: 0.45;
}
&:hover {
> .n-icon {
opacity: 1;
}
background-color: #f7f7f7;
}
}
.result-content-container {
width: 90%;
.main-content {
flex: 1;
width: 100%;
display: flex;
justify-content: center;
align-items: center;
background-color: #fff;
}
}
</style>

View File

@ -1,5 +1,5 @@
<!doctype html>
<html lang="en">
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<base target="_blank" />