mirror of
https://github.com/primedigitaltech/azon_seeker.git
synced 2026-01-19 13:13:22 +08:00
Update UI & Worker
This commit is contained in:
parent
ee8d6c1e0a
commit
6de45ebeb2
@ -39,6 +39,7 @@
|
||||
"chokidar": "^4.0.3",
|
||||
"cross-env": "^7.0.3",
|
||||
"crx": "^5.0.1",
|
||||
"dayjs": "^1.11.13",
|
||||
"emittery": "^1.1.0",
|
||||
"esno": "^4.8.0",
|
||||
"fs-extra": "^11.2.0",
|
||||
|
||||
11
pnpm-lock.yaml
generated
11
pnpm-lock.yaml
generated
@ -44,6 +44,9 @@ importers:
|
||||
crx:
|
||||
specifier: ^5.0.1
|
||||
version: 5.0.1
|
||||
dayjs:
|
||||
specifier: ^1.11.13
|
||||
version: 1.11.13
|
||||
emittery:
|
||||
specifier: ^1.1.0
|
||||
version: 1.1.0
|
||||
@ -2073,6 +2076,12 @@ packages:
|
||||
integrity: sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==,
|
||||
}
|
||||
|
||||
dayjs@1.11.13:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==,
|
||||
}
|
||||
|
||||
debounce@1.2.1:
|
||||
resolution:
|
||||
{
|
||||
@ -7364,6 +7373,8 @@ snapshots:
|
||||
|
||||
date-fns@3.6.0: {}
|
||||
|
||||
dayjs@1.11.13: {}
|
||||
|
||||
debounce@1.2.1: {}
|
||||
|
||||
debug@2.6.9:
|
||||
|
||||
40
src/components/DetailDescription.vue
Normal file
40
src/components/DetailDescription.vue
Normal file
@ -0,0 +1,40 @@
|
||||
<script lang="ts" setup>
|
||||
import type { AmazonDetailItem } from '~/logic/page-worker/types';
|
||||
|
||||
const props = defineProps<{ model: AmazonDetailItem }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="detail-description">
|
||||
<n-descriptions label-placement="left" bordered :column="4">
|
||||
<n-descriptions-item label="ASIN" :span="2">
|
||||
{{ props.model.asin }}
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="评价">
|
||||
{{ props.model.rating || '-' }}
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="评论数">
|
||||
{{ props.model.ratingCount || '-' }}
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="大类">
|
||||
{{ props.model.category1?.name || '-' }}
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="排名">
|
||||
{{ props.model.category1?.rank || '-' }}
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="小类">
|
||||
{{ props.model.category2?.name || '-' }}
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="排名">
|
||||
{{ props.model.category2?.rank || '-' }}
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="图片链接">
|
||||
<div v-for="link in props.model.imageUrls">
|
||||
{{ link }}
|
||||
</div>
|
||||
</n-descriptions-item>
|
||||
</n-descriptions>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
35
src/components/HeaderMenu.vue
Normal file
35
src/components/HeaderMenu.vue
Normal file
@ -0,0 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
const openOptionsPage = async () => {
|
||||
await browser.runtime.openOptionsPage();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="header-menu">
|
||||
<n-button class="setting-button" round @click="openOptionsPage" size="small">
|
||||
<template #icon>
|
||||
<n-icon size="18" color="#0f0f0f">
|
||||
<stash:search-results />
|
||||
</n-icon>
|
||||
</template>
|
||||
<template #default> 数据 </template>
|
||||
</n-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.header-menu {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: row-reverse;
|
||||
justify-content: flex-start;
|
||||
|
||||
.setting-button {
|
||||
margin-right: 20px;
|
||||
opacity: 0.7;
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
44
src/components/ProgressReport.vue
Normal file
44
src/components/ProgressReport.vue
Normal file
@ -0,0 +1,44 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
timelines: {
|
||||
type: 'default' | 'error' | 'success' | 'warning' | 'info';
|
||||
title: string;
|
||||
time: string;
|
||||
content: string;
|
||||
}[];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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="50">
|
||||
<solar-cat-linear />
|
||||
</n-icon>
|
||||
</template>
|
||||
<template #default>还未开始</template>
|
||||
</n-empty>
|
||||
</n-card>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.progress-report {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
@ -1,15 +1,61 @@
|
||||
<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';
|
||||
import { exportToXLSX, Header, importFromXLSX } from '~/logic/data-io';
|
||||
import type { AmazonDetailItem, AmazonItem } from '~/logic/page-worker/types';
|
||||
import { allItems } from '~/logic/storage';
|
||||
import DetailDescription from './DetailDescription.vue';
|
||||
|
||||
const message = useMessage();
|
||||
|
||||
const page = reactive({ current: 1, size: 10 });
|
||||
const resultSearchText = ref('');
|
||||
const columns: (TableColumn<AmazonGoodsLinkItem> & { hidden?: boolean })[] = [
|
||||
const filter = reactive({
|
||||
keywords: null as string | null,
|
||||
search: '',
|
||||
detailOnly: false,
|
||||
});
|
||||
const filterFormItems = computed(() => {
|
||||
const records = allItems.value;
|
||||
return [
|
||||
{
|
||||
prop: 'keywords',
|
||||
label: '关键词',
|
||||
type: 'select',
|
||||
params: {
|
||||
options: [
|
||||
...records.reduce((o, c) => {
|
||||
o.add(c.keywords);
|
||||
return o;
|
||||
}, new Set<string>()),
|
||||
].map((opt) => ({
|
||||
label: opt,
|
||||
value: opt,
|
||||
})),
|
||||
},
|
||||
},
|
||||
];
|
||||
});
|
||||
const onFilterReset = () => {
|
||||
Object.assign(filter, {
|
||||
keywords: null as string | null,
|
||||
search: '',
|
||||
detailOnly: false,
|
||||
});
|
||||
};
|
||||
|
||||
const columns: (TableColumn<AmazonItem> & { hidden?: boolean })[] = [
|
||||
{
|
||||
type: 'expand',
|
||||
expandable: (row) => row.hasDetail,
|
||||
renderExpand(row) {
|
||||
return h(DetailDescription, { model: row as AmazonDetailItem }, () => '');
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '关键词',
|
||||
key: 'keywords',
|
||||
minWidth: 120,
|
||||
},
|
||||
{
|
||||
title: '排位',
|
||||
key: 'rank',
|
||||
@ -25,7 +71,7 @@ const columns: (TableColumn<AmazonGoodsLinkItem> & { hidden?: boolean })[] = [
|
||||
{
|
||||
title: 'ASIN',
|
||||
key: 'asin',
|
||||
minWidth: 120,
|
||||
minWidth: 130,
|
||||
},
|
||||
{
|
||||
title: '图片',
|
||||
@ -33,7 +79,12 @@ const columns: (TableColumn<AmazonGoodsLinkItem> & { hidden?: boolean })[] = [
|
||||
hidden: true,
|
||||
},
|
||||
{
|
||||
title: '链接',
|
||||
title: '创建日期',
|
||||
key: 'createTime',
|
||||
minWidth: 160,
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'link',
|
||||
render(row) {
|
||||
return h(
|
||||
@ -43,17 +94,10 @@ const columns: (TableColumn<AmazonGoodsLinkItem> & { hidden?: boolean })[] = [
|
||||
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,
|
||||
});
|
||||
}
|
||||
await browser.tabs.create({
|
||||
active: true,
|
||||
url: row.link,
|
||||
});
|
||||
},
|
||||
},
|
||||
() => '前往',
|
||||
@ -62,72 +106,125 @@ const columns: (TableColumn<AmazonGoodsLinkItem> & { hidden?: boolean })[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const itemView = computed(() => {
|
||||
const itemView = computed<{ records: AmazonItem[]; pageCount: number }>(() => {
|
||||
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 data = filterItemData(allItems.value); // Filter Data
|
||||
let pageCount = ~~(data.length / size);
|
||||
pageCount += data.length % size > 0 ? 1 : 0;
|
||||
data = data.slice((current - 1) * size, current * size);
|
||||
return { data, pageCount };
|
||||
return { records: data, pageCount };
|
||||
});
|
||||
|
||||
const extraHeaders: Header[] = [
|
||||
{
|
||||
prop: 'hasDetail',
|
||||
label: '有详情',
|
||||
formatOutputValue: (val: boolean) => (val ? '是' : '否'),
|
||||
parseImportValue: (val: string) => val === '是',
|
||||
},
|
||||
{ prop: 'rating', label: '评分' },
|
||||
{ prop: 'ratingCount', label: '评论数' },
|
||||
{ prop: 'category1.name', label: '大类' },
|
||||
{ prop: 'category1.rank', label: '大类排行' },
|
||||
{ prop: 'category2.name', label: '小类' },
|
||||
{ prop: 'category2.rank', label: '小类排行' },
|
||||
{
|
||||
prop: 'imageUrls',
|
||||
label: '商品图片链接',
|
||||
formatOutputValue: (val?: string[]) => val?.join(';'),
|
||||
parseImportValue: (val?: string) => val?.split(';'),
|
||||
},
|
||||
];
|
||||
|
||||
const filterItemData = (data: AmazonItem[]): AmazonItem[] => {
|
||||
const { search, detailOnly, keywords } = filter;
|
||||
if (search.trim() !== '') {
|
||||
data = data.filter((r) => {
|
||||
return [r.title, r.asin, r.keywords].some((field) =>
|
||||
field.toLowerCase().includes(search.toLowerCase()),
|
||||
);
|
||||
});
|
||||
}
|
||||
if (detailOnly) {
|
||||
data = data.filter((r) => r.hasDetail);
|
||||
}
|
||||
if (keywords) {
|
||||
data = data.filter((r) => r.keywords === keywords);
|
||||
}
|
||||
return data;
|
||||
};
|
||||
|
||||
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 });
|
||||
const headers: Header[] = 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 }[],
|
||||
)
|
||||
.concat(extraHeaders);
|
||||
const data = filterItemData(allItems.value);
|
||||
exportToXLSX(data, { 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} 条数据`);
|
||||
}
|
||||
if (fileList.length !== 1 || fileList[0].file === null) {
|
||||
return;
|
||||
}
|
||||
const file: File = fileList.pop()!.file!;
|
||||
const headers: Header[] = 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 }[],
|
||||
)
|
||||
.concat(extraHeaders);
|
||||
const importedData = await importFromXLSX<AmazonItem>(file, { headers });
|
||||
allItems.value = importedData; // 覆盖原数据
|
||||
message.info(`成功导入 ${file.name} 文件 ${importedData.length} 条数据`);
|
||||
};
|
||||
|
||||
const handleClearData = async () => {
|
||||
allItems.value = [];
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="result-table">
|
||||
<n-card class="result-content-container" title="结果框">
|
||||
<template #header-extra>
|
||||
<n-card class="result-content-container">
|
||||
<template #header>
|
||||
<n-space>
|
||||
<div style="padding-right: 10px">结果框</div>
|
||||
<n-switch size="small" class="filter-switch" v-model:value="filter.detailOnly">
|
||||
<template #checked> 详情 </template>
|
||||
<template #unchecked> 全部</template>
|
||||
</n-switch>
|
||||
</n-space>
|
||||
</template>
|
||||
<template #header-extra>
|
||||
<n-space size="small">
|
||||
<n-input
|
||||
v-model:value="resultSearchText"
|
||||
v-model:value="filter.search"
|
||||
size="small"
|
||||
placeholder="输入关键词查询结果"
|
||||
placeholder="输入文本过滤结果"
|
||||
round
|
||||
/>
|
||||
<n-popconfirm @positive-click="items = []" positive-text="确定" negative-text="取消">
|
||||
<n-popconfirm
|
||||
placement="bottom"
|
||||
@positive-click="handleClearData"
|
||||
positive-text="确定"
|
||||
negative-text="取消"
|
||||
>
|
||||
<template #trigger>
|
||||
<n-button type="primary" tertiary round size="small">
|
||||
<n-button type="error" tertiary circle size="small">
|
||||
<template #icon>
|
||||
<ion-trash-outline />
|
||||
</template>
|
||||
@ -137,22 +234,47 @@ const handleImport: UploadOnChange = async ({ fileList }) => {
|
||||
</n-popconfirm>
|
||||
<n-upload
|
||||
accept="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
:max="1"
|
||||
@change="handleImport"
|
||||
>
|
||||
<n-button type="primary" tertiary round size="small">
|
||||
<n-button type="primary" tertiary circle size="small">
|
||||
<template #icon>
|
||||
<gg-import />
|
||||
</template>
|
||||
</n-button>
|
||||
</n-upload>
|
||||
<n-button type="primary" tertiary round size="small" @click="handleExport">
|
||||
<n-button type="primary" tertiary circle size="small" @click="handleExport">
|
||||
<template #icon>
|
||||
<ion-arrow-up-right-box-outline />
|
||||
</template>
|
||||
</n-button>
|
||||
<n-popover trigger="hover" placement="bottom">
|
||||
<template #trigger>
|
||||
<n-button type="primary" tertiary circle size="small">
|
||||
<template #icon>
|
||||
<ant-design-filter-outlined />
|
||||
</template>
|
||||
</n-button>
|
||||
</template>
|
||||
<div class="filter-section">
|
||||
<div class="filter-title">筛选器</div>
|
||||
<n-form :model="filter" label-placement="left">
|
||||
<n-form-item v-for="item in filterFormItems" :label="item.label">
|
||||
<n-select
|
||||
v-if="item.type === 'select'"
|
||||
placeholder=""
|
||||
v-model:value="filter.keywords"
|
||||
clearable
|
||||
:options="item.params.options"
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<div class="filter-footer" @click="onFilterReset"><n-button>重置</n-button></div>
|
||||
</div>
|
||||
</n-popover>
|
||||
</n-space>
|
||||
</template>
|
||||
<n-empty v-if="items.length === 0" size="huge" style="padding-top: 40px">
|
||||
<n-empty v-if="allItems.length === 0" size="huge" style="padding-top: 40px">
|
||||
<template #icon>
|
||||
<n-icon size="60">
|
||||
<solar-cat-linear />
|
||||
@ -164,16 +286,19 @@ const handleImport: UploadOnChange = async ({ fileList }) => {
|
||||
</n-empty>
|
||||
<n-space vertical v-else>
|
||||
<n-data-table
|
||||
:row-key="(row: any) => `${row.asin}-${~~(Math.random() * 10000)}`"
|
||||
: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
|
||||
:data="itemView.records"
|
||||
/>
|
||||
<div class="data-pagination">
|
||||
<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
|
||||
/>
|
||||
</div>
|
||||
</n-space>
|
||||
</n-card>
|
||||
</div>
|
||||
@ -183,4 +308,27 @@ const handleImport: UploadOnChange = async ({ fileList }) => {
|
||||
.result-content-container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
:deep(.filter-switch) {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.data-pagination {
|
||||
display: flex;
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
.filter-section {
|
||||
width: 250px;
|
||||
|
||||
.filter-title {
|
||||
font-size: 18px;
|
||||
padding: 5px 0 15px 0;
|
||||
}
|
||||
|
||||
.filter-footer {
|
||||
display: flex;
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1,10 +0,0 @@
|
||||
<template>
|
||||
<p class="shared-subtitle">This is the {{ $app.context }} page</p>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.shared-subtitle {
|
||||
font-size: 1.5rem;
|
||||
color: #333;
|
||||
}
|
||||
</style>
|
||||
@ -1,5 +1,45 @@
|
||||
import { utils, read, writeFileXLSX } from 'xlsx';
|
||||
|
||||
function getAttribute<T extends unknown>(
|
||||
obj: Record<string, unknown>,
|
||||
path: string,
|
||||
): T | undefined {
|
||||
const keys = path.split('.');
|
||||
let result: unknown = obj;
|
||||
|
||||
for (const key of keys) {
|
||||
if (result && typeof result === 'object' && key in result) {
|
||||
result = (result as Record<string, unknown>)[key];
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return result as T;
|
||||
}
|
||||
|
||||
function setAttribute(obj: Record<string, unknown>, path: string, value: unknown): void {
|
||||
const keys = path.split('.');
|
||||
let current: Record<string, unknown> = obj;
|
||||
|
||||
for (const key of keys.slice(0, keys.length - 1)) {
|
||||
if (!current[key] || typeof current[key] !== 'object') {
|
||||
current[key] = {};
|
||||
}
|
||||
current = current[key] as Record<string, unknown>;
|
||||
}
|
||||
|
||||
const finalKey = keys[keys.length - 1];
|
||||
current[finalKey] = value;
|
||||
}
|
||||
|
||||
export type Header = {
|
||||
label: string;
|
||||
prop: string;
|
||||
parseImportValue?: (val: any) => any;
|
||||
formatOutputValue?: (val: any) => any;
|
||||
};
|
||||
|
||||
/**
|
||||
* 导出为XLSX文件
|
||||
* @param data 数据数组
|
||||
@ -7,17 +47,28 @@ import { utils, read, writeFileXLSX } from 'xlsx';
|
||||
*/
|
||||
export function exportToXLSX(
|
||||
data: Record<string, unknown>[],
|
||||
options: { fileName?: string; headers?: { label: string; prop: string }[] } = {},
|
||||
options: {
|
||||
fileName?: string;
|
||||
headers?: Header[];
|
||||
} = {},
|
||||
): void {
|
||||
if (!data.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const headers = options.headers || Object.keys(data[0]).map((k) => ({ label: k, prop: k }));
|
||||
const headers: Header[] =
|
||||
options.headers || Object.keys(data[0]).map((k) => ({ label: k, prop: k }));
|
||||
const rows = data.map((item) => {
|
||||
const row: Record<string, unknown> = {};
|
||||
headers.forEach((header) => {
|
||||
row[header.label] = item[header.prop];
|
||||
const value = getAttribute(item, header.prop);
|
||||
if (header.formatOutputValue) {
|
||||
row[header.label] = header.formatOutputValue(value);
|
||||
} else if (['string', 'number', 'bigint', 'boolean'].includes(typeof value)) {
|
||||
row[header.label] = value;
|
||||
} else {
|
||||
row[header.label] = JSON.stringify(value);
|
||||
}
|
||||
});
|
||||
return row;
|
||||
});
|
||||
@ -39,7 +90,7 @@ export function exportToXLSX(
|
||||
*/
|
||||
export async function importFromXLSX<T extends Record<string, unknown>>(
|
||||
file: File,
|
||||
options: { headers?: { label: string; prop: string }[] } = {},
|
||||
options: { headers?: Header[] } = {},
|
||||
): Promise<T[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
@ -56,7 +107,10 @@ export async function importFromXLSX<T extends Record<string, unknown>>(
|
||||
jsonData = jsonData.map((item) => {
|
||||
const mappedItem: Record<string, unknown> = {};
|
||||
options.headers?.forEach((header) => {
|
||||
mappedItem[header.prop] = item[header.label];
|
||||
const value = header.parseImportValue
|
||||
? header.parseImportValue(item[header.label])
|
||||
: item[header.label];
|
||||
setAttribute(mappedItem, header.prop, value);
|
||||
});
|
||||
return mappedItem as T;
|
||||
});
|
||||
|
||||
@ -36,11 +36,20 @@ class AmazonPageWorkerImpl implements AmazonPageWorker {
|
||||
return tab;
|
||||
}
|
||||
|
||||
private async createNewTab(url: string): Promise<Tabs.Tab> {
|
||||
const tab = await browser.tabs.create({
|
||||
url,
|
||||
active: true,
|
||||
});
|
||||
return tab;
|
||||
}
|
||||
|
||||
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
|
||||
await exec(tabId, async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500 + ~~(500 * Math.random())));
|
||||
window.scrollBy(0, ~~(Math.random() * 500) + 500);
|
||||
while (!document.querySelector('.s-pagination-strip')) {
|
||||
window.scrollBy(0, ~~(Math.random() * 500) + 500);
|
||||
await new Promise((resolve) => setTimeout(resolve, ~~(Math.random() * 50) + 50));
|
||||
@ -159,15 +168,20 @@ class AmazonPageWorkerImpl implements AmazonPageWorker {
|
||||
this._interruptSignal = true;
|
||||
};
|
||||
this.channel.on('error', stop);
|
||||
let result = {
|
||||
hasNextPage: true,
|
||||
data: [] as unknown[],
|
||||
};
|
||||
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 }[],
|
||||
});
|
||||
let offset = 0;
|
||||
while (!this._interruptSignal) {
|
||||
const { hasNextPage, data } = await this.wanderSearchSinglePage(tab);
|
||||
const objs = data.map((r, i) => ({
|
||||
...r,
|
||||
rank: offset + 1 + i,
|
||||
createTime: new Date().toLocaleString(),
|
||||
asin: /(?<=\/dp\/)[A-Z0-9]{10}/.exec(r.link as string)![0],
|
||||
}));
|
||||
this.channel.emit('item-links-collected', { objs });
|
||||
offset += data.length;
|
||||
if (!hasNextPage) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
this._interruptSignal = false;
|
||||
this.channel.off('error', stop);
|
||||
@ -175,7 +189,6 @@ class AmazonPageWorkerImpl implements AmazonPageWorker {
|
||||
}
|
||||
|
||||
public async wanderDetailPage(entry: string): Promise<void> {
|
||||
const tab = await this.getCurrentTab();
|
||||
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)!;
|
||||
@ -185,16 +198,12 @@ class AmazonPageWorkerImpl implements AmazonPageWorker {
|
||||
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: params.url,
|
||||
});
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
const tab = await this.createNewTab(params.url);
|
||||
//#region Await Production Introduction Element Loaded and Determine Page Pattern
|
||||
const pattern = await exec(tab.id!, async () => {
|
||||
window.scrollBy(0, ~~(Math.random() * 500) + 500);
|
||||
let targetNode = document.querySelector('#prodDetails, #detailBulletsWrapper_feature_div');
|
||||
while (!targetNode) {
|
||||
while (!targetNode || document.readyState === 'loading') {
|
||||
window.scrollBy(0, ~~(Math.random() * 500) + 500);
|
||||
await new Promise((resolve) => setTimeout(resolve, ~~(Math.random() * 50) + 50));
|
||||
targetNode = document.querySelector('#prodDetails, #detailBulletsWrapper_feature_div');
|
||||
@ -258,13 +267,17 @@ class AmazonPageWorkerImpl implements AmazonPageWorker {
|
||||
break;
|
||||
}
|
||||
if (rawRankingText) {
|
||||
const [category1Statement, category2Statement] = rawRankingText.split('\n');
|
||||
let statement = /#[0-9,]+\sin\s\S[\s\w&\(\)]+/.exec(rawRankingText)![0]!;
|
||||
const category1Name = /(?<=in\s).+(?=\s\(See)/.exec(statement)?.[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;
|
||||
Number(/(?<=#)[0-9,]+/.exec(statement)?.[0].replace(',', '')) || null;
|
||||
|
||||
rawRankingText = rawRankingText.replace(statement, '');
|
||||
statement = /#[0-9,]+\sin\s\S[\s\w&\(\)]+/.exec(rawRankingText)![0]!;
|
||||
const category2Name = /(?<=in\s).+/.exec(statement)?.[0].replace(/[\s]+$/, '') || null;
|
||||
const category2Ranking =
|
||||
Number(/(?<=#)[0-9,]+/.exec(category2Statement)?.[0].replace(',', '')) || null; // "," should be removed
|
||||
const category2Name = /(?<=in\s).+/.exec(category2Statement)?.[0] || null;
|
||||
Number(/(?<=#)[0-9,]+/.exec(statement)?.[0].replace(',', '')) || null;
|
||||
|
||||
this.channel.emit('item-category-rank-collected', {
|
||||
asin: params.asin,
|
||||
category1: ![category1Name, category1Ranking].includes(null)
|
||||
@ -281,7 +294,7 @@ class AmazonPageWorkerImpl implements AmazonPageWorker {
|
||||
let urls = [
|
||||
...(document.querySelectorAll('.imageThumbnail img') as unknown as HTMLImageElement[]),
|
||||
].map((e) => e.src);
|
||||
// https://github.com/primedigitaltech/azon_seeker/issues/4
|
||||
//#region process more images 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) {
|
||||
@ -298,14 +311,27 @@ class AmazonPageWorkerImpl implements AmazonPageWorker {
|
||||
return url;
|
||||
});
|
||||
}
|
||||
//#endregion
|
||||
//#region post-process image urls
|
||||
urls = urls.map((rawUrl) => {
|
||||
const imgUrl = new URL(rawUrl);
|
||||
const paths = imgUrl.pathname.split('/');
|
||||
const chunks = paths[paths.length - 1].split('.');
|
||||
const [name, ext] = [chunks[0], chunks[chunks.length - 1]];
|
||||
paths[paths.length - 1] = `${name}.${ext}`;
|
||||
imgUrl.pathname = paths.join('/');
|
||||
return imgUrl.toString();
|
||||
});
|
||||
//#endregion
|
||||
return urls;
|
||||
});
|
||||
imageUrls &&
|
||||
this.channel.emit('item-images-collected', {
|
||||
asin: entry,
|
||||
urls: imageUrls,
|
||||
asin: params.asin,
|
||||
imageUrls,
|
||||
});
|
||||
//#endregion
|
||||
await browser.tabs.remove(tab.id!);
|
||||
}
|
||||
|
||||
public async stop(): Promise<void> {
|
||||
|
||||
34
src/logic/page-worker/types.d.ts
vendored
34
src/logic/page-worker/types.d.ts
vendored
@ -1,44 +1,46 @@
|
||||
import type Emittery from 'emittery';
|
||||
|
||||
type AmazonGoodsLinkItem = {
|
||||
type AmazonSearchItem = {
|
||||
keywords: string;
|
||||
link: string;
|
||||
title: string;
|
||||
asin: string;
|
||||
rank: number;
|
||||
imageSrc: string;
|
||||
createTime: string;
|
||||
};
|
||||
|
||||
type AmazonDetailItem = {
|
||||
asin: string;
|
||||
rating?: number;
|
||||
ratingCount?: number;
|
||||
category1?: { name: string; rank: number };
|
||||
category2?: { name: string; rank: number };
|
||||
imageUrls: string[];
|
||||
};
|
||||
|
||||
type AmazonItem = AmazonSearchItem & Partial<AmazonDetailItem> & { hasDetail: boolean };
|
||||
|
||||
interface AmazonPageWorkerEvents {
|
||||
/**
|
||||
* The event is fired when worker collected links to items on the Amazon search page.
|
||||
*/
|
||||
['item-links-collected']: { objs: { link: string; title: string; imageSrc: string }[] };
|
||||
['item-links-collected']: { objs: Omit<AmazonSearchItem, 'keywords'>[] };
|
||||
|
||||
/**
|
||||
* The event is fired when worker collected goods' rating on the Amazon detail page.
|
||||
*/
|
||||
['item-rating-collected']: {
|
||||
asin: string;
|
||||
rating: number;
|
||||
ratingCount: number;
|
||||
};
|
||||
['item-rating-collected']: Pick<AmazonDetailItem, 'asin' | 'rating' | 'ratingCount'>;
|
||||
|
||||
/**
|
||||
* The event is fired when worker
|
||||
*/
|
||||
['item-category-rank-collected']: {
|
||||
asin: string;
|
||||
category1?: { name: string; rank: number };
|
||||
category2?: { name: string; rank: number };
|
||||
};
|
||||
['item-category-rank-collected']: Pick<AmazonDetailItem, 'asin' | 'category1' | 'category2'>;
|
||||
|
||||
/**
|
||||
* The event is fired when images collected
|
||||
*/
|
||||
['item-images-collected']: {
|
||||
asin: string;
|
||||
urls: string[];
|
||||
};
|
||||
['item-images-collected']: Pick<AmazonDetailItem, 'asin' | 'imageUrls'>;
|
||||
|
||||
/**
|
||||
* Error event that occurs when there is an issue with the Amazon page worker.
|
||||
|
||||
@ -1,6 +1,60 @@
|
||||
import { useWebExtensionStorage } from '~/composables/useWebExtensionStorage';
|
||||
import type { AmazonGoodsLinkItem } from './page-worker/types';
|
||||
import type { AmazonDetailItem, AmazonItem, AmazonSearchItem } from './page-worker/types';
|
||||
|
||||
export const keywords = useWebExtensionStorage<string>('keywords', '');
|
||||
|
||||
export const itemList = useWebExtensionStorage<AmazonGoodsLinkItem[]>('itemList', []);
|
||||
export const searchItems = useWebExtensionStorage<AmazonSearchItem[]>('itemList', []);
|
||||
|
||||
export const detailItems = useWebExtensionStorage<{ [asin: string]: AmazonDetailItem }>(
|
||||
'detailItems',
|
||||
{},
|
||||
);
|
||||
|
||||
export const allItems = computed({
|
||||
get() {
|
||||
const sItems = searchItems.value;
|
||||
const dItems = detailItems.value;
|
||||
return sItems.map<AmazonItem>((si) => {
|
||||
const asin = si.asin;
|
||||
return asin in dItems
|
||||
? { ...si, ...dItems[asin], hasDetail: true }
|
||||
: { ...si, hasDetail: false };
|
||||
});
|
||||
},
|
||||
set(newValue) {
|
||||
searchItems.value = newValue.map((row) => {
|
||||
const props: (keyof AmazonSearchItem)[] = [
|
||||
'keywords',
|
||||
'asin',
|
||||
'title',
|
||||
'imageSrc',
|
||||
'link',
|
||||
'rank',
|
||||
'createTime',
|
||||
];
|
||||
const entries: [string, unknown][] = Object.entries(row).filter(([key]) =>
|
||||
props.includes(key as keyof AmazonSearchItem),
|
||||
);
|
||||
return Object.fromEntries(entries) as AmazonSearchItem;
|
||||
});
|
||||
detailItems.value = newValue
|
||||
.filter((row) => row.hasDetail)
|
||||
.reduce<Record<string, AmazonDetailItem>>((o, row) => {
|
||||
const { asin } = row;
|
||||
const props: (keyof AmazonDetailItem)[] = [
|
||||
'asin',
|
||||
'category1',
|
||||
'category2',
|
||||
'imageUrls',
|
||||
'rating',
|
||||
'ratingCount',
|
||||
];
|
||||
const entries: [string, unknown][] = Object.entries(row).filter(([key]) =>
|
||||
props.includes(key as keyof AmazonDetailItem),
|
||||
);
|
||||
const item = Object.fromEntries(entries) as AmazonDetailItem;
|
||||
o[asin] = item;
|
||||
return o;
|
||||
}, {});
|
||||
},
|
||||
});
|
||||
|
||||
@ -14,7 +14,7 @@ main {
|
||||
align-items: center;
|
||||
|
||||
.result-table {
|
||||
width: 70%;
|
||||
width: 90%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1,39 +1,181 @@
|
||||
<script setup lang="ts">
|
||||
import type { FormRules, UploadOnChange } from 'naive-ui';
|
||||
import pageWorkerFactory from '~/logic/page-worker';
|
||||
import { detailItems } from '~/logic/storage';
|
||||
|
||||
const inputText = ref('');
|
||||
const data = ref<Record<string, unknown>>({});
|
||||
const worker = pageWorkerFactory.useAmazonPageWorker();
|
||||
const message = useMessage();
|
||||
|
||||
onMounted(() => {
|
||||
worker.channel.on('item-rating-collected', (ev) => {
|
||||
console.log('item-rating-collected', ev);
|
||||
data.value = { ...data.value, ...ev };
|
||||
const formItem = reactive({ asin: '' });
|
||||
const formRef = useTemplateRef('detail-form');
|
||||
const formRules: FormRules = {
|
||||
asin: [
|
||||
{
|
||||
required: true,
|
||||
trigger: ['submit', 'blur'],
|
||||
message: '请输入格式正确的ASIN',
|
||||
validator: (_rule, val: string) => {
|
||||
return (
|
||||
typeof val === 'string' &&
|
||||
val.match(/^[A-Z0-9]{10}((\n|\s|,|;)[A-Z0-9]{10})*\n?$/g) !== null
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const timelines = ref<
|
||||
{
|
||||
type: 'default' | 'error' | 'success' | 'warning' | 'info';
|
||||
title: string;
|
||||
time: string;
|
||||
content: string;
|
||||
}[]
|
||||
>([]);
|
||||
|
||||
const worker = pageWorkerFactory.useAmazonPageWorker(); // 获取Page Worker单例
|
||||
worker.channel.on('error', ({ message: msg }) => {
|
||||
timelines.value.push({
|
||||
type: 'error',
|
||||
title: '错误',
|
||||
time: new Date().toLocaleString(),
|
||||
content: msg,
|
||||
});
|
||||
worker.channel.on('item-category-rank-collected', (ev) => {
|
||||
console.log('item-category-rank-collected', ev);
|
||||
data.value = { ...data.value, ...ev };
|
||||
message.error(msg);
|
||||
});
|
||||
worker.channel.on('item-rating-collected', (ev) => {
|
||||
timelines.value.push({
|
||||
type: 'success',
|
||||
title: `商品: ${ev.asin}`,
|
||||
time: new Date().toLocaleString(),
|
||||
content: `评分: ${ev.rating};评价数:${ev.ratingCount}`,
|
||||
});
|
||||
worker.channel.on('item-images-collected', (ev) => {
|
||||
console.log('item-images-collected', ev);
|
||||
data.value = { ...data.value, ...ev };
|
||||
detailItems.value[ev.asin] = { ...detailItems.value[ev.asin], ...ev };
|
||||
});
|
||||
worker.channel.on('item-category-rank-collected', (ev) => {
|
||||
timelines.value.push({
|
||||
type: 'success',
|
||||
title: `商品: ${ev.asin}`,
|
||||
time: new Date().toLocaleString(),
|
||||
content: [
|
||||
ev.category1 ? `#${ev.category1.rank} in ${ev.category1.name}` : '',
|
||||
ev.category2 ? `#${ev.category2.rank} in ${ev.category2.name}` : '',
|
||||
].join('\n'),
|
||||
});
|
||||
detailItems.value[ev.asin] = { ...detailItems.value[ev.asin], ...ev };
|
||||
});
|
||||
worker.channel.on('item-images-collected', (ev) => {
|
||||
timelines.value.push({
|
||||
type: 'success',
|
||||
title: `商品: ${ev.asin}`,
|
||||
time: new Date().toLocaleString(),
|
||||
content: `图片数: ${ev.imageUrls.length}`,
|
||||
});
|
||||
detailItems.value[ev.asin] = { ...detailItems.value[ev.asin], ...ev };
|
||||
});
|
||||
|
||||
const handleGetInfo = async () => {
|
||||
worker.wanderDetailPage(inputText.value);
|
||||
const handleImportAsin: UploadOnChange = ({ fileList }) => {
|
||||
if (fileList.length > 0) {
|
||||
const file = fileList.pop();
|
||||
if (file && file.file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const content = e.target?.result;
|
||||
if (typeof content === 'string') {
|
||||
formItem.asin = content;
|
||||
}
|
||||
};
|
||||
reader.readAsText(file.file, 'utf-8');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportAsin = () => {
|
||||
const blob = new Blob([formItem.asin], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const filename = `asin-${new Date().toISOString()}.txt`;
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
message.info('导出完成');
|
||||
};
|
||||
|
||||
const handleGetInfo = () => {
|
||||
formRef.value?.validate(async (errors) => {
|
||||
if (errors) {
|
||||
message.error('格式错误,请检查输入');
|
||||
return;
|
||||
}
|
||||
const asinList = formItem.asin.split(/\n|\s|,|;/).filter((item) => item.length > 0);
|
||||
if (asinList.length > 0) {
|
||||
timelines.value = [
|
||||
{
|
||||
type: 'info',
|
||||
title: '开始',
|
||||
time: new Date().toLocaleString(),
|
||||
content: '开始数据采集',
|
||||
},
|
||||
];
|
||||
for (const asin of asinList) {
|
||||
await worker.wanderDetailPage(asin);
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
||||
}
|
||||
timelines.value.push({
|
||||
type: 'info',
|
||||
title: '结束',
|
||||
time: new Date().toLocaleString(),
|
||||
content: '数据采集完成',
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
</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 language="json" @click="handleGetInfo">Get Info</n-button>
|
||||
<n-code>{{ data }}</n-code>
|
||||
<header-menu />
|
||||
<div class="title">
|
||||
<n-icon :size="60"> <mdi-cat style="color: black" /> </n-icon>
|
||||
<h1 style="font-size: 30px; color: black">Detail Page</h1>
|
||||
</div>
|
||||
<div class="interative-section">
|
||||
<n-space>
|
||||
<n-upload @change="handleImportAsin" accept=".txt" :max="1">
|
||||
<n-button round size="small">
|
||||
<template #icon>
|
||||
<gg-import />
|
||||
</template>
|
||||
导入
|
||||
</n-button>
|
||||
</n-upload>
|
||||
<n-button @click="handleExportAsin" round size="small">
|
||||
<template #icon>
|
||||
<ion-arrow-up-right-box-outline />
|
||||
</template>
|
||||
导出
|
||||
</n-button>
|
||||
</n-space>
|
||||
<n-form :rules="formRules" :model="formItem" ref="detail-form" label-placement="left">
|
||||
<n-form-item style="padding-top: 0px" path="asin">
|
||||
<n-input
|
||||
v-model:value="formItem.asin"
|
||||
placeholder="输入ASINs"
|
||||
type="textarea"
|
||||
size="large"
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<n-button class="start-button" round size="large" type="primary" @click="handleGetInfo">
|
||||
<template #icon>
|
||||
<ant-design-thunderbolt-outlined />
|
||||
</template>
|
||||
开始
|
||||
</n-button>
|
||||
</div>
|
||||
<progress-report class="progress-report" :timelines="timelines" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -44,6 +186,30 @@ const handleGetInfo = async () => {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
background-color: #f0f0f0;
|
||||
|
||||
.title {
|
||||
margin: 20px 0 30px 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.interative-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 85%;
|
||||
padding: 15px 15px;
|
||||
border-radius: 10px;
|
||||
border: 1px #00000020 dashed;
|
||||
margin: 0 0 10px 0;
|
||||
}
|
||||
|
||||
.progress-report {
|
||||
margin-top: 20px;
|
||||
width: 95%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1,12 +1,39 @@
|
||||
<script setup lang="ts">
|
||||
import { keywords } from '~/logic/storage';
|
||||
import pageWorker from '~/logic/page-worker';
|
||||
import type { AmazonGoodsLinkItem } from '~/logic/page-worker/types';
|
||||
import type { AmazonSearchItem } from '~/logic/page-worker/types';
|
||||
import { NButton } from 'naive-ui';
|
||||
import { itemList as items } from '~/logic/storage';
|
||||
import { searchItems } from '~/logic/storage';
|
||||
|
||||
const message = useMessage();
|
||||
//#region Initial Page Worker
|
||||
const worker = pageWorker.useAmazonPageWorker();
|
||||
worker.channel.on('error', ({ message: msg }) => {
|
||||
timelines.value.push({
|
||||
type: 'error',
|
||||
title: '错误',
|
||||
time: new Date().toLocaleString(),
|
||||
content: msg,
|
||||
});
|
||||
message.error(msg);
|
||||
worker.stop();
|
||||
});
|
||||
worker.channel.on('item-links-collected', ({ objs }) => {
|
||||
timelines.value.push({
|
||||
type: 'success',
|
||||
title: '检测到数据',
|
||||
time: new Date().toLocaleString(),
|
||||
content: `成功采集到 ${objs.length} 条数据`,
|
||||
});
|
||||
const addedRows = objs.map<AmazonSearchItem>((v) => {
|
||||
return {
|
||||
...v,
|
||||
keywords: keywords.value,
|
||||
};
|
||||
});
|
||||
searchItems.value = searchItems.value.concat(addedRows);
|
||||
});
|
||||
//#endregion
|
||||
const workerRunning = ref(false);
|
||||
|
||||
const timelines = ref<
|
||||
@ -18,20 +45,6 @@ const timelines = ref<
|
||||
}[]
|
||||
>([]);
|
||||
|
||||
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;
|
||||
});
|
||||
items.value = items.value.concat(addedRows);
|
||||
};
|
||||
|
||||
const onCollectStart = async () => {
|
||||
workerRunning.value = true;
|
||||
timelines.value = [
|
||||
@ -45,19 +58,10 @@ const onCollectStart = async () => {
|
||||
if (keywords.value.trim() === '') {
|
||||
return;
|
||||
}
|
||||
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);
|
||||
//#region start page worker
|
||||
await worker.doSearch(keywords.value);
|
||||
await worker.wanderSearchPage();
|
||||
worker.channel.off('item-links-collected', onItemLinksCollected);
|
||||
//#endregion
|
||||
timelines.value.push({
|
||||
type: 'info',
|
||||
title: '结束',
|
||||
@ -69,83 +73,54 @@ const onCollectStart = async () => {
|
||||
|
||||
const onCollectStop = async () => {
|
||||
workerRunning.value = false;
|
||||
worker.stop();
|
||||
message.info('停止收集');
|
||||
};
|
||||
|
||||
const openOptionsPage = async () => {
|
||||
await browser.runtime.openOptionsPage();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="search-page-worker">
|
||||
<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>
|
||||
<header-menu />
|
||||
<n-space class="app-title">
|
||||
<mdi-cat style="font-size: 60px; color: black" />
|
||||
<h1>Azon Seeker</h1>
|
||||
<h1>Search Page</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="!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="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"
|
||||
<div class="interactive-section">
|
||||
<n-space>
|
||||
<n-input
|
||||
:disabled="workerRunning"
|
||||
v-model:value="keywords"
|
||||
class="search-input-box"
|
||||
autosize
|
||||
size="large"
|
||||
round
|
||||
placeholder="请输入关键词采集信息"
|
||||
>
|
||||
{{ item.content }}
|
||||
</n-timeline-item>
|
||||
</n-timeline>
|
||||
<n-empty v-else size="large">
|
||||
<template #icon>
|
||||
<n-icon size="50">
|
||||
<solar-cat-linear />
|
||||
</n-icon>
|
||||
</template>
|
||||
<template #default>还未开始</template>
|
||||
</n-empty>
|
||||
</n-card>
|
||||
<template #prefix>
|
||||
<n-icon size="20">
|
||||
<ion-search />
|
||||
</n-icon>
|
||||
</template>
|
||||
</n-input>
|
||||
<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>
|
||||
<div style="height: 10px"></div>
|
||||
<progress-report class="progress-report" :timelines="timelines" />
|
||||
</main>
|
||||
</template>
|
||||
|
||||
@ -158,30 +133,22 @@ const openOptionsPage = async () => {
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
|
||||
.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: 240px;
|
||||
.interactive-section {
|
||||
padding: 10px 15px;
|
||||
border-radius: 10px;
|
||||
border: 1px #00000020 dashed;
|
||||
|
||||
.search-input-box {
|
||||
min-width: 240px;
|
||||
}
|
||||
}
|
||||
|
||||
.progress-report {
|
||||
width: 95%;
|
||||
width: 90%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -23,12 +23,24 @@ const showHeader = ref(false);
|
||||
<template>
|
||||
<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-tabs
|
||||
placement="top"
|
||||
:default-value="tabs[0].name"
|
||||
type="segment"
|
||||
:value="selectedTab"
|
||||
@update:value="
|
||||
(val) => {
|
||||
if (tabs.findIndex((t) => t.name === val) !== -1) {
|
||||
selectedTab = val;
|
||||
}
|
||||
}
|
||||
"
|
||||
>
|
||||
<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">
|
||||
<n-icon size="18">
|
||||
<ion-chevron-up v-if="showHeader" />
|
||||
<ion-chevron-down v-else />
|
||||
</n-icon>
|
||||
@ -66,7 +78,7 @@ const showHeader = ref(false);
|
||||
cursor: pointer;
|
||||
|
||||
> .n-icon {
|
||||
opacity: 0.45;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user