mirror of
https://github.com/primedigitaltech/azon_seeker.git
synced 2026-02-01 12:25:34 +08:00
Update UI & Worker
This commit is contained in:
parent
80f5fedd0d
commit
ee8d6c1e0a
@ -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}`));
|
||||
|
||||
186
src/components/ResultTable.vue
Normal file
186
src/components/ResultTable.vue
Normal 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>
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
5
src/logic/page-worker/types.d.ts
vendored
5
src/logic/page-worker/types.d.ts
vendored
@ -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>;
|
||||
}
|
||||
|
||||
@ -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
25
src/options/App.vue
Normal 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
20
src/options/Options.vue
Normal 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
12
src/options/index.html
Normal 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
7
src/options/main.ts
Normal 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');
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -4,11 +4,11 @@ import SearchPageWorker from './SearchPageWorker.vue';
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
name: 'Search Page',
|
||||
name: '搜索页',
|
||||
component: SearchPageWorker,
|
||||
},
|
||||
{
|
||||
name: 'Detail Page',
|
||||
name: '详情页',
|
||||
component: DetailPageWorker,
|
||||
},
|
||||
];
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user