Update UI & Worker

This commit is contained in:
johnathan 2025-05-07 17:50:40 +08:00
parent ee8d6c1e0a
commit 6de45ebeb2
15 changed files with 818 additions and 268 deletions

View File

@ -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
View File

@ -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:

View 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>

View 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>

View 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>

View File

@ -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>

View File

@ -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>

View File

@ -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;
});

View File

@ -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> {

View File

@ -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.

View File

@ -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;
}, {});
},
});

View File

@ -14,7 +14,7 @@ main {
align-items: center;
.result-table {
width: 70%;
width: 90%;
}
}
</style>

View File

@ -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>

View File

@ -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>

View File

@ -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 {