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/", "webRoot": "${workspaceFolder}/src/",
"port": 9222, "port": 9222,
"urlFilter": "chrome-extension://fmalpbpehdilmjhnanhpjmnkgbahopfj/*" "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": { "lint-staged": {
"**/*": "prettier --write --ignore-unknown" "**/*": "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 Emittery from 'emittery';
import type { AmazonGoodsLinkItem, AmazonPageWorker, AmazonPageWorkerEvents } from './types'; import type { AmazonPageWorker, AmazonPageWorkerEvents } from './types';
import type { Tabs } from 'webextension-polyfill'; import type { Tabs } from 'webextension-polyfill';
import { exec } from '../execute-script'; import { exec } from '../execute-script';
@ -56,9 +56,9 @@ class AmazonPageWorkerImpl implements AmazonPageWorker {
const pagePattern = await exec(tabId, async () => { const pagePattern = await exec(tabId, async () => {
return [ return [
...(document.querySelectorAll<HTMLDivElement>( ...(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[]), ) as unknown as HTMLDivElement[]),
].filter((e) => e.getClientRects().length > 0).length === 0 ].filter((e) => e.getClientRects().length > 0).length > 0
? 'pattern-1' ? 'pattern-1'
: 'pattern-2'; : 'pattern-2';
}); });
@ -68,24 +68,28 @@ class AmazonPageWorkerImpl implements AmazonPageWorker {
} }
// #endregion // #endregion
// #region Retrieve key nodes and their information from the critical product search page // #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) { switch (pagePattern) {
// 处理商品以列表形式展示的情况 // 处理商品以列表形式展示的情况
case 'pattern-1': case 'pattern-1':
data = await exec(tabId, async () => { data = await exec(tabId, async () => {
const items = [ const items = [
...(document.querySelectorAll<HTMLDivElement>( ...(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[]), ) as unknown as HTMLDivElement[]),
].filter((e) => e.getClientRects().length > 0); ].filter((e) => e.getClientRects().length > 0);
const linkObjs = items.reduce<AmazonGoodsLinkItem[]>((objs, el) => { const linkObjs = items.reduce<{ link: string; title: string; imageSrc: string }[]>(
const link = el.querySelector<HTMLAnchorElement>('a')?.href; (objs, el) => {
const title = el const link = el.querySelector<HTMLAnchorElement>('a')?.href;
.querySelector<HTMLHeadingElement>('h2.a-color-base') const title = el
?.getAttribute('aria-label'); .querySelector<HTMLHeadingElement>('h2.a-color-base')!
link && objs.push({ link, title: title || '' }); .getAttribute('aria-label')!;
return objs; const imageSrc = el.querySelector<HTMLImageElement>('img.s-image')!.src!;
}, []); link && objs.push({ link, title, imageSrc });
return objs;
},
[],
);
return linkObjs; return linkObjs;
}); });
break; break;
@ -94,15 +98,19 @@ class AmazonPageWorkerImpl implements AmazonPageWorker {
data = await exec(tabId, async () => { data = await exec(tabId, async () => {
const items = [ const items = [
...(document.querySelectorAll<HTMLDivElement>( ...(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[]), ) as unknown as HTMLDivElement[]),
].filter((e) => e.getClientRects().length > 0); ].filter((e) => e.getClientRects().length > 0);
const linkObjs = items.reduce<AmazonGoodsLinkItem[]>((objs, el) => { const linkObjs = items.reduce<{ link: string; title: string; imageSrc: string }[]>(
const link = el.querySelector<HTMLAnchorElement>('a.a-link-normal')?.href; (objs, el) => {
const title = el.querySelector<HTMLHeadingElement>('h2.a-color-base')?.innerText; const link = el.querySelector<HTMLAnchorElement>('a.a-link-normal')?.href;
link && objs.push({ link, title: title || '' }); const title = el.querySelector<HTMLHeadingElement>('h2.a-color-base')!.innerText;
return objs; const imageSrc = el.querySelector<HTMLImageElement>('img.s-image')!.src!;
}, []); link && objs.push({ link, title, imageSrc });
return objs;
},
[],
);
return linkObjs; return linkObjs;
}); });
break; break;
@ -141,21 +149,36 @@ class AmazonPageWorkerImpl implements AmazonPageWorker {
stopSignal = true; stopSignal = true;
}; };
this.channel.on('error', stop); this.channel.on('error', stop);
let result = { hasNextPage: true, data: [] as AmazonGoodsLinkItem[] }; let result = {
hasNextPage: true,
data: [] as unknown[],
};
while (result.hasNextPage && !stopSignal) { while (result.hasNextPage && !stopSignal) {
result = await this.wanderSearchSinglePage(tab); 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); this.channel.off('error', stop);
return new Promise((resolve) => setTimeout(resolve, 1000)); 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(); 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!, { 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 //#region Await Production Introduction Element Loaded and Determine Page Pattern
const pattern = await exec(tab.id!, async () => { const pattern = await exec(tab.id!, async () => {
@ -188,7 +211,7 @@ class AmazonPageWorkerImpl implements AmazonPageWorker {
}); });
if (ratingInfo && (ratingInfo.rating !== 0 || ratingInfo.ratingCount !== 0)) { if (ratingInfo && (ratingInfo.rating !== 0 || ratingInfo.ratingCount !== 0)) {
this.channel.emit('item-rating-collected', { this.channel.emit('item-rating-collected', {
asin, asin: params.asin,
...ratingInfo, ...ratingInfo,
}); });
} }
@ -230,7 +253,7 @@ class AmazonPageWorkerImpl implements AmazonPageWorker {
const category2Ranking = Number(/(?<=#)\d+/.exec(category2Statement)?.[0]) || null; const category2Ranking = Number(/(?<=#)\d+/.exec(category2Statement)?.[0]) || null;
const category2Name = /(?<=in\s).+/.exec(category2Statement)?.[0] || null; const category2Name = /(?<=in\s).+/.exec(category2Statement)?.[0] || null;
this.channel.emit('item-category-rank-collected', { this.channel.emit('item-category-rank-collected', {
asin, asin: params.asin,
category1: ![category1Name, category1Ranking].includes(null) category1: ![category1Name, category1Ranking].includes(null)
? { name: category1Name!, rank: category1Ranking! } ? { name: category1Name!, rank: category1Ranking! }
: undefined, : undefined,
@ -253,7 +276,7 @@ class AmazonPageWorkerImpl implements AmazonPageWorker {
}); });
imageUrls && imageUrls &&
this.channel.emit('item-images-collected', { this.channel.emit('item-images-collected', {
asin, asin: entry,
urls: imageUrls, urls: imageUrls,
}); });
//#endregion //#endregion

View File

@ -1,12 +1,18 @@
import type Emittery from 'emittery'; 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 { interface AmazonPageWorkerEvents {
/** /**
* The event is fired when worker collected links to items on the Amazon search page. * 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. * 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. * 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 { 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> <script lang="ts" setup>
import SidePanel from './SidePanel.vue'; import SidePanel from './SidePanel.vue';
import TestPanel from './TestPanel.vue';
</script> </script>
<template> <template>
<!-- Naive UI Wrapper-->
<n-modal-provider> <n-modal-provider>
<n-dialog-provider> <n-dialog-provider>
<n-message-provider> <n-message-provider>
<test-panel /> <side-panel />
</n-message-provider> </n-message-provider>
</n-dialog-provider> </n-dialog-provider>
</n-modal-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"> <script setup lang="ts">
import { keywords } from '~/logic/storage'; import DetailPageWorker from './DetailPageWorker.vue';
import pageWorker from '~/logic/page-worker'; import SearchPageWorker from './SearchPageWorker.vue';
import type { AmazonGoodsLinkItem } from '~/logic/page-worker/types';
import { NButton, type DataTableColumns } from 'naive-ui';
const message = useMessage(); const tabs = [
const worker = pageWorker.useAmazonPageWorker();
type TableData = AmazonGoodsLinkItem & { rank: number };
const items = ref<AmazonGoodsLinkItem[]>([]);
const page = reactive({ current: 1, size: 5 });
const columns: DataTableColumns<TableData> = [
{ {
title: '排位', name: 'Search Page',
key: 'rank', component: SearchPageWorker,
}, },
{ {
title: '标题', name: 'Detail Page',
key: 'title', component: DetailPageWorker,
},
{
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 selectedTab = ref(tabs[0].name);
const itemView = computed(() => { const currentComponent = computed(() => {
const { current, size } = page; const tab = tabs.find((tab) => tab.name === selectedTab.value);
return items.value return tab ? tab.component : null;
.slice((current - 1) * size, current * size)
.map((v, i) => ({ ...v, rank: 1 + (current - 1) * size + i }));
}); });
const showHeader = ref(false);
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('完成');
};
</script> </script>
<template> <template>
<main class="side-panel"> <div class="side-panel">
<n-space class="app-header"> <div class="header-menu" v-if="showHeader">
<mdi-cat style="font-size: 60px; color: black" /> <n-tabs placement="top" :default-value="tabs[0].name" type="card" v-model:value="selectedTab">
<h1>Azon Seeker</h1> <n-tab v-for="tab in tabs" :name="tab.name" />
</n-space> </n-tabs>
<n-space> </div>
<n-input <div class="display-header-button" @click="showHeader = !showHeader">
v-model:value="keywords" <n-icon size="22">
class="search-input-box" <ion-chevron-up v-if="showHeader" />
autosize <ion-chevron-down v-else />
size="large" </n-icon>
round </div>
placeholder="请输入关键词" <div class="main-content">
/> <keep-alive>
<n-button type="primary" round size="large" @click="onCollect">采集</n-button> <Component :is="currentComponent" />
</n-space> </keep-alive>
<div style="height: 10px"></div> </div>
<n-card class="result-content-container" title="结果框"> </div>
<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>
</template> </template>
<style lang="scss" scoped> <style scoped lang="scss">
.side-panel { .side-panel {
width: 100%; width: 100%;
height: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: center;
align-items: center; align-items: center;
gap: 20px; background-color: #f0f0f0;
.app-header { .header-menu {
margin-top: 100px; width: 100%;
background-color: #fff;
box-shadow: 0px 2px 4px rgba(0, 0, 0, 0.1);
border-bottom: 1px solid #eaeaea;
} }
.search-input-box { .display-header-button {
min-width: 270px; 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 { .main-content {
width: 90%; flex: 1;
width: 100%;
display: flex;
justify-content: center;
align-items: center;
background-color: #fff;
} }
} }
</style> </style>

View File

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