Update UI & Worker

This commit is contained in:
johnathan 2025-04-29 16:41:47 +08:00
parent 80f5fedd0d
commit ee8d6c1e0a
15 changed files with 490 additions and 186 deletions

View File

@ -8,7 +8,7 @@ import { isDev, log, port, r } from './utils';
* Stub index.html to use Vite in development
*/
async function stubIndexHtml() {
const views = ['sidepanel'];
const views = ['sidepanel', 'options'];
for (const view of views) {
await fs.ensureDir(r(`extension/dist/${view}`));

View File

@ -0,0 +1,186 @@
<script setup lang="ts">
import { NButton, UploadOnChange } from 'naive-ui';
import type { TableColumn } from 'naive-ui/es/data-table/src/interface';
import { exportToXLSX, importFromXLSX } from '~/logic/data-io';
import type { AmazonGoodsLinkItem } from '~/logic/page-worker/types';
import { itemList as items } from '~/logic/storage';
const message = useMessage();
const page = reactive({ current: 1, size: 10 });
const resultSearchText = ref('');
const columns: (TableColumn<AmazonGoodsLinkItem> & { hidden?: boolean })[] = [
{
title: '排位',
key: 'rank',
minWidth: 60,
},
{
title: '标题',
key: 'title',
render(row) {
return h('div', { style: {} }, `${row.title}`);
},
},
{
title: 'ASIN',
key: 'asin',
minWidth: 120,
},
{
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 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('导出完成');
};
const handleImport: UploadOnChange = async ({ fileList }) => {
if (fileList.length > 0) {
const file = fileList.pop();
if (file && file.file) {
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 }[],
);
const importedData = await importFromXLSX<AmazonGoodsLinkItem>(file.file, { headers });
items.value = importedData; //
message.info(`成功导入 ${file?.file?.name} 文件 ${importedData.length} 条数据`);
}
}
};
</script>
<template>
<div class="result-table">
<n-card class="result-content-container" title="结果框">
<template #header-extra>
<n-space>
<n-input
v-model:value="resultSearchText"
size="small"
placeholder="输入关键词查询结果"
round
/>
<n-popconfirm @positive-click="items = []" positive-text="确定" negative-text="取消">
<template #trigger>
<n-button type="primary" tertiary round size="small">
<template #icon>
<ion-trash-outline />
</template>
</n-button>
</template>
确认清空所有数据吗
</n-popconfirm>
<n-upload
accept="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
@change="handleImport"
>
<n-button type="primary" tertiary round size="small">
<template #icon>
<gg-import />
</template>
</n-button>
</n-upload>
<n-button type="primary" tertiary round size="small" @click="handleExport">
<template #icon>
<ion-arrow-up-right-box-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, 15, 20, 25]"
show-size-picker
/>
</n-space>
</n-card>
</div>
</template>
<style lang="scss" scoped>
.result-content-container {
width: 100%;
}
</style>

View File

@ -1,4 +1,4 @@
import { utils, writeFileXLSX } from 'xlsx';
import { utils, read, writeFileXLSX } from 'xlsx';
/**
* XLSX文件
@ -7,19 +7,13 @@ import { utils, writeFileXLSX } from 'xlsx';
*/
export function exportToXLSX(
data: Record<string, unknown>[],
options: { fileName?: string; headers?: { label: string; prop: string }[]; index?: boolean } = {},
options: { fileName?: string; headers?: { label: string; prop: string }[] } = {},
): 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) => {
@ -36,3 +30,47 @@ export function exportToXLSX(
const fileName = options.fileName || `export_${new Date().toISOString().slice(0, 10)}.xlsx`;
writeFileXLSX(workbook, fileName, { bookType: 'xlsx', type: 'binary', compression: true });
}
/**
* XLSX文件导入数据
* @param file XLSX文件对象
* @param options
* @returns
*/
export async function importFromXLSX<T extends Record<string, unknown>>(
file: File,
options: { headers?: { label: string; prop: string }[] } = {},
): Promise<T[]> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (event) => {
try {
const data = new Uint8Array(event.target?.result as ArrayBuffer);
const workbook = read(data, { type: 'array' });
const sheetName = workbook.SheetNames[0]; // 默认读取第一个工作表
const worksheet = workbook.Sheets[sheetName];
let jsonData = utils.sheet_to_json<T>(worksheet);
if (options.headers) {
jsonData = jsonData.map((item) => {
const mappedItem: Record<string, unknown> = {};
options.headers?.forEach((header) => {
mappedItem[header.prop] = item[header.label];
});
return mappedItem as T;
});
}
resolve(jsonData);
} catch (error) {
reject(error);
}
};
reader.onerror = (error) => {
reject(error);
};
reader.readAsArrayBuffer(file);
});
}

View File

@ -8,6 +8,7 @@ import { exec } from '../execute-script';
* **can't** run on content script!
*/
class AmazonPageWorkerImpl implements AmazonPageWorker {
//#region Singleton
private static _instance: AmazonPageWorker | null = null;
public static getInstance() {
if (this._instance === null) {
@ -16,9 +17,18 @@ class AmazonPageWorkerImpl implements AmazonPageWorker {
return this._instance;
}
private constructor() {}
//#endregion
/**
* The channel for communication with the Amazon page worker.
*/
readonly channel = new Emittery<AmazonPageWorkerEvents>();
/**
* The signal to interrupt the current operation.
*/
private _interruptSignal = false;
private async getCurrentTab(): Promise<Tabs.Tab> {
const tab = await browser.tabs
.query({ active: true, currentWindow: true })
@ -26,21 +36,6 @@ class AmazonPageWorkerImpl implements AmazonPageWorker {
return tab;
}
public async doSearch(keywords: string): Promise<string> {
const url = new URL('https://www.amazon.com/s');
url.searchParams.append('k', keywords);
const tab = await browser.tabs
.query({ active: true, currentWindow: true })
.then((tabs) => tabs[0]);
const currentUrl = new URL(tab.url!);
if (currentUrl.hostname !== url.hostname || currentUrl.searchParams.get('k') !== keywords) {
await browser.tabs.update(tab.id, { url: url.toString() });
await new Promise<void>((resolve) => setTimeout(resolve, 1000));
}
return url.toString();
}
private async wanderSearchSinglePage(tab: Tabs.Tab) {
const tabId = tab.id!;
// #region Wait for the Next button to appear, indicating that the product items have finished loading
@ -142,23 +137,39 @@ class AmazonPageWorkerImpl implements AmazonPageWorker {
return { data, hasNextPage };
}
public async doSearch(keywords: string): Promise<string> {
const url = new URL('https://www.amazon.com/s');
url.searchParams.append('k', keywords);
const tab = await browser.tabs
.query({ active: true, currentWindow: true })
.then((tabs) => tabs[0]);
const currentUrl = new URL(tab.url!);
if (currentUrl.hostname !== url.hostname || currentUrl.searchParams.get('k') !== keywords) {
await browser.tabs.update(tab.id, { url: url.toString() });
await new Promise<void>((resolve) => setTimeout(resolve, 1000));
}
return url.toString();
}
public async wanderSearchPage(): Promise<void> {
const tab = await this.getCurrentTab();
let stopSignal = false;
this._interruptSignal = false;
const stop = async (_: unknown): Promise<void> => {
stopSignal = true;
this._interruptSignal = true;
};
this.channel.on('error', stop);
let result = {
hasNextPage: true,
data: [] as unknown[],
};
while (result.hasNextPage && !stopSignal) {
while (result.hasNextPage && !this._interruptSignal) {
result = await this.wanderSearchSinglePage(tab);
this.channel.emit('item-links-collected', {
objs: result.data as { link: string; title: string; imageSrc: string }[],
});
}
this._interruptSignal = false;
this.channel.off('error', stop);
return new Promise((resolve) => setTimeout(resolve, 1000));
}
@ -248,9 +259,11 @@ class AmazonPageWorkerImpl implements AmazonPageWorker {
}
if (rawRankingText) {
const [category1Statement, category2Statement] = rawRankingText.split('\n');
const category1Ranking = Number(/(?<=#)\d+/.exec(category1Statement)?.[0]) || null;
const category1Ranking =
Number(/(?<=#)[0-9,]+/.exec(category1Statement)?.[0].replace(',', '')) || null; // "," should be removed
const category1Name = /(?<=in\s).+(?=\s\(See)/.exec(category1Statement)?.[0] || null;
const category2Ranking = Number(/(?<=#)\d+/.exec(category2Statement)?.[0]) || null;
const category2Ranking =
Number(/(?<=#)[0-9,]+/.exec(category2Statement)?.[0].replace(',', '')) || null; // "," should be removed
const category2Name = /(?<=in\s).+/.exec(category2Statement)?.[0] || null;
this.channel.emit('item-category-rank-collected', {
asin: params.asin,
@ -265,14 +278,27 @@ class AmazonPageWorkerImpl implements AmazonPageWorker {
//#endregion
//#region Fetch Goods' Images
const imageUrls = await exec(tab.id!, async () => {
const node = document.evaluate(
`//div[@id='imgTagWrapperId']/img`,
document,
null,
XPathResult.FIRST_ORDERED_NODE_TYPE,
null,
).singleNodeValue as HTMLImageElement | null;
return node ? [node.getAttribute('src')!] : null;
let urls = [
...(document.querySelectorAll('.imageThumbnail img') as unknown as HTMLImageElement[]),
].map((e) => e.src);
// https://github.com/primedigitaltech/azon_seeker/issues/4
if (document.querySelector('.overlayRestOfImages')) {
const overlay = document.querySelector<HTMLDivElement>('.overlayRestOfImages')!;
if (document.querySelector<HTMLDivElement>('#ivThumbs')!.getClientRects().length === 0) {
overlay.click();
await new Promise((resolve) => setTimeout(resolve, 1000));
}
urls = [
...(document.querySelectorAll(
'#ivThumbs .ivThumbImage[style]',
) as unknown as HTMLDivElement[]),
].map((e) => e.style.background);
urls = urls.map((s) => {
const [url] = /(?<=url\(").+(?=")/.exec(s)!;
return url;
});
}
return urls;
});
imageUrls &&
this.channel.emit('item-images-collected', {
@ -281,6 +307,10 @@ class AmazonPageWorkerImpl implements AmazonPageWorker {
});
//#endregion
}
public async stop(): Promise<void> {
this._interruptSignal = true;
}
}
class PageWorkerFactory {

View File

@ -70,4 +70,9 @@ interface AmazonPageWorker {
* @param entry Product link or Amazon Standard Identification Number.
*/
wanderDetailPage(entry: string): Promise<void>;
/**
* Stop the worker.
*/
stop(): Promise<void>;
}

View File

@ -16,6 +16,10 @@ export async function getManifest() {
action: {
default_icon: './assets/icon-512.png',
},
options_ui: {
page: './dist/options/index.html',
open_in_tab: true,
},
background: isFirefox
? {
scripts: ['dist/background/index.mjs'],
@ -63,14 +67,5 @@ export async function getManifest() {
};
}
// FIXME: not work in MV3
if (isDev && false) {
// for content script, as browsers will cache them for each reload,
// we use a background script to always inject the latest version
// see src/background/contentScriptHMR.ts
delete manifest.content_scripts;
manifest.permissions?.push('webNavigation');
}
return manifest;
}

25
src/options/App.vue Normal file
View File

@ -0,0 +1,25 @@
<script lang="ts" setup>
import Options from './Options.vue';
</script>
<template>
<!-- Naive UI Wrapper-->
<n-config-provider
:theme-overrides="{
common: {
primaryColor: '#007bff',
primaryColorHover: '#0056b3',
primaryColorPressed: '#004085',
primaryColorSuppl: '#003366',
},
}"
>
<n-modal-provider>
<n-dialog-provider>
<n-message-provider>
<options />
</n-message-provider>
</n-dialog-provider>
</n-modal-provider>
</n-config-provider>
</template>

20
src/options/Options.vue Normal file
View File

@ -0,0 +1,20 @@
<script setup lang="ts"></script>
<template>
<main>
<h1>采集结果</h1>
<result-table class="result-table" />
</main>
</template>
<style scoped lang="scss">
main {
display: flex;
flex-direction: column;
align-items: center;
.result-table {
width: 70%;
}
}
</style>

12
src/options/index.html Normal file
View File

@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<base target="_blank" />
<title>Options</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="./main.ts"></script>
</body>
</html>

7
src/options/main.ts Normal file
View File

@ -0,0 +1,7 @@
import App from './App.vue';
import { setupApp } from '~/logic/common-setup';
import '../styles';
const app = createApp(App);
setupApp(app);
app.mount('#app');

View File

@ -4,11 +4,22 @@ import SidePanel from './SidePanel.vue';
<template>
<!-- Naive UI Wrapper-->
<n-modal-provider>
<n-dialog-provider>
<n-message-provider>
<side-panel />
</n-message-provider>
</n-dialog-provider>
</n-modal-provider>
<n-config-provider
:theme-overrides="{
common: {
primaryColor: '#007bff',
primaryColorHover: '#0056b3',
primaryColorPressed: '#004085',
primaryColorSuppl: '#003366',
},
}"
>
<n-modal-provider>
<n-dialog-provider>
<n-message-provider>
<side-panel />
</n-message-provider>
</n-dialog-provider>
</n-modal-provider>
</n-config-provider>
</template>

View File

@ -32,8 +32,8 @@ const handleGetInfo = async () => {
<n-input v-model:value="inputText" />
</n-form-item>
</n-form>
<n-button @click="handleGetInfo">Get Info</n-button>
<div>{{ data }}</div>
<n-button language="json" @click="handleGetInfo">Get Info</n-button>
<n-code>{{ data }}</n-code>
</div>
</template>

View File

@ -1,83 +1,30 @@
<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 workerRunning = ref(false);
const page = reactive({ current: 1, size: 5 });
const resultSearchText = ref('');
const columns: (TableColumn<AmazonGoodsLinkItem> & { hidden?: boolean })[] = [
const timelines = ref<
{
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 };
});
type: 'default' | 'error' | 'success' | 'warning' | 'info';
title: string;
time: string;
content: string;
}[]
>([]);
const onItemLinksCollected = (ev: { objs: Record<string, unknown>[] }) => {
timelines.value.push({
type: 'success',
title: '检测到数据',
time: new Date().toLocaleString(),
content: `成功采集到 ${ev.objs.length} 条数据`,
});
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;
@ -86,39 +33,63 @@ const onItemLinksCollected = (ev: { objs: Record<string, unknown>[] }) => {
};
const onCollectStart = async () => {
workerRunning.value = true;
timelines.value = [
{
type: 'info',
title: '开始',
time: new Date().toLocaleString(),
content: '开始数据采集',
},
];
if (keywords.value.trim() === '') {
return;
}
message.info('开始收集');
items.value = [];
worker.channel.on('error', ({ message: msg }) => {
timelines.value.push({
type: 'error',
title: '错误',
time: new Date().toLocaleString(),
content: 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('完成');
timelines.value.push({
type: 'info',
title: '结束',
time: new Date().toLocaleString(),
content: '数据采集完成',
});
workerRunning.value = false;
};
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('导出完成');
const onCollectStop = async () => {
workerRunning.value = false;
message.info('停止收集');
};
const openOptionsPage = async () => {
await browser.runtime.openOptionsPage();
};
</script>
<template>
<main class="search-page-worker">
<n-space class="app-header">
<div class="header-menu">
<n-button :disabled="workerRunning" class="setting-button" round @click="openOptionsPage">
<template #icon>
<n-icon size="20" color="#0f0f0f">
<stash:search-results />
</n-icon>
</template>
<template #default> 数据 </template>
</n-button>
</div>
<n-space class="app-title">
<mdi-cat style="font-size: 60px; color: black" />
<h1>Azon Seeker</h1>
</n-space>
@ -137,53 +108,43 @@ const handleExport = async () => {
</n-icon>
</template>
</n-input>
<n-button type="primary" round size="large" @click="onCollectStart">采集</n-button>
<n-button
type="primary"
round
size="large"
@click="!workerRunning ? onCollectStart() : onCollectStop()"
>
<template #icon>
<n-icon v-if="!workerRunning" size="20">
<ant-design-thunderbolt-outlined />
</n-icon>
<n-icon v-else size="20">
<ion-stop-outline />
</n-icon>
</template>
</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">
<n-card class="progress-report" title="数据获取情况">
<n-timeline v-if="timelines.length > 0">
<n-timeline-item
v-for="(item, index) in timelines"
:key="index"
:type="item.type"
:title="item.title"
:time="item.time"
>
{{ item.content }}
</n-timeline-item>
</n-timeline>
<n-empty v-else size="large">
<template #icon>
<n-icon size="60">
<n-icon size="50">
<solar-cat-linear />
</n-icon>
</template>
<template #default>
<h3>还没有数据哦</h3>
</template>
<template #default>还未开始</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>
@ -197,16 +158,30 @@ const handleExport = async () => {
align-items: center;
gap: 20px;
.app-header {
margin-top: 100px;
.header-menu {
width: 95%;
display: flex;
flex-direction: row-reverse;
justify-content: flex-start;
.setting-button {
opacity: 0.7;
&:hover {
opacity: 1;
}
}
}
.app-title {
margin-top: 60px;
}
.search-input-box {
min-width: 270px;
min-width: 240px;
}
.result-content-container {
width: 90%;
.progress-report {
width: 95%;
}
}
</style>

View File

@ -4,11 +4,11 @@ import SearchPageWorker from './SearchPageWorker.vue';
const tabs = [
{
name: 'Search Page',
name: '搜索页',
component: SearchPageWorker,
},
{
name: 'Detail Page',
name: '详情页',
component: DetailPageWorker,
},
];

View File

@ -5,7 +5,7 @@
<base target="_blank" />
<title>Sidepanel</title>
</head>
<body style="min-width: 100px">
<body style="min-width: 300px">
<div id="app"></div>
<script type="module" src="./main.ts"></script>
</body>