Update UI & Worker

This commit is contained in:
johnathan 2025-05-09 15:49:23 +08:00
parent f68b72fbb6
commit 804c2f60c6
12 changed files with 303 additions and 249 deletions

View File

@ -19,7 +19,7 @@ defineProps<{
:title="item.title" :title="item.title"
:time="item.time" :time="item.time"
> >
{{ item.content }} <div v-for="line in item.content.split('\n')">{{ line }}</div>
</n-timeline-item> </n-timeline-item>
</n-timeline> </n-timeline>
<n-empty v-else size="large"> <n-empty v-else size="large">

View File

@ -35,6 +35,7 @@ const filterFormItems = computed(() => {
}, },
]; ];
}); });
const onFilterReset = () => { const onFilterReset = () => {
Object.assign(filter, { Object.assign(filter, {
keywords: null as string | null, keywords: null as string | null,
@ -74,7 +75,7 @@ const columns: (TableColumn<AmazonItem> & { hidden?: boolean })[] = [
minWidth: 130, minWidth: 130,
}, },
{ {
title: '', title: '封面图',
key: 'imageSrc', key: 'imageSrc',
hidden: true, hidden: true,
}, },
@ -84,7 +85,7 @@ const columns: (TableColumn<AmazonItem> & { hidden?: boolean })[] = [
minWidth: 160, minWidth: 160,
}, },
{ {
title: '操作', title: '链接',
key: 'link', key: 'link',
render(row) { render(row) {
return h( return h(
@ -106,14 +107,15 @@ const columns: (TableColumn<AmazonItem> & { hidden?: boolean })[] = [
}, },
]; ];
const itemView = computed<{ records: AmazonItem[]; pageCount: number }>(() => { const itemView = computed<{ records: AmazonItem[]; pageCount: number; origin: AmazonItem[] }>(
const { current, size } = page; () => {
let data = filterItemData(allItems.value); // Filter Data const { current, size } = page;
let pageCount = ~~(data.length / size); let data = filterItemData(allItems.value); // Filter Data
pageCount += data.length % size > 0 ? 1 : 0; let pageCount = ~~(data.length / size);
data = data.slice((current - 1) * size, current * size); pageCount += data.length % size > 0 ? 1 : 0;
return { records: data, pageCount }; return { records: data.slice((current - 1) * size, current * size), pageCount, origin: data };
}); },
);
const extraHeaders: Header[] = [ const extraHeaders: Header[] = [
{ {
@ -166,7 +168,7 @@ const handleExport = async () => {
[] as { label: string; prop: string }[], [] as { label: string; prop: string }[],
) )
.concat(extraHeaders); .concat(extraHeaders);
const data = filterItemData(allItems.value); const { origin: data } = itemView.value;
exportToXLSX(data, { headers }); exportToXLSX(data, { headers });
message.info('导出完成'); message.info('导出完成');
}; };
@ -202,7 +204,7 @@ const handleClearData = async () => {
<n-card class="result-content-container"> <n-card class="result-content-container">
<template #header> <template #header>
<n-space> <n-space>
<div style="padding-right: 10px">结果</div> <div style="padding-right: 10px">结果数据表</div>
<n-switch size="small" class="filter-switch" v-model:value="filter.detailOnly"> <n-switch size="small" class="filter-switch" v-model:value="filter.detailOnly">
<template #checked> 详情 </template> <template #checked> 详情 </template>
<template #unchecked> 全部</template> <template #unchecked> 全部</template>

View File

@ -76,6 +76,9 @@ export function exportToXLSX(
const worksheet = utils.json_to_sheet(rows, { const worksheet = utils.json_to_sheet(rows, {
header: headers.map((h) => h.label), header: headers.map((h) => h.label),
}); });
worksheet['!autofilter'] = {
ref: utils.encode_range({ c: 0, r: 0 }, { c: headers.length - 1, r: rows.length }),
}; // Use Auto Filter https://github.com/SheetJS/sheetjs/issues/472#issuecomment-292852308
const workbook = utils.book_new(); const workbook = utils.book_new();
utils.book_append_sheet(workbook, worksheet, 'Sheet1'); utils.book_append_sheet(workbook, worksheet, 'Sheet1');
const fileName = options.fileName || `export_${new Date().toISOString().slice(0, 10)}.xlsx`; const fileName = options.fileName || `export_${new Date().toISOString().slice(0, 10)}.xlsx`;

View File

@ -40,6 +40,7 @@ export async function exec<T, P extends Record<string, unknown>>(
const ret = injectResults.pop(); const ret = injectResults.pop();
if (ret?.error) { if (ret?.error) {
console.error('注入脚本时发生错误', ret.error); console.error('注入脚本时发生错误', ret.error);
throw new Error('注入脚本时发生错误');
} }
return ret?.result as T | null; return ret?.result as T | null;
} }

View File

@ -1,8 +1,30 @@
import Emittery from 'emittery'; import Emittery from 'emittery';
import type { AmazonPageWorker, AmazonPageWorkerEvents } from './types'; import type { AmazonDetailItem, AmazonPageWorker, AmazonPageWorkerEvents } from './types';
import type { Tabs } from 'webextension-polyfill'; import type { Tabs } from 'webextension-polyfill';
import { exec } from '../execute-script'; import { exec } from '../execute-script';
/**
* Process unknown errors.
*/
function withErrorHandling(
target: (this: AmazonPageWorker, ...args: any[]) => Promise<any>,
_context: ClassMethodDecoratorContext,
): (this: AmazonPageWorker, ...args: any[]) => Promise<any> {
// target 就是当前被装饰的 class 方法
const originalMethod = target;
// 定义一个新方法
const decoratedMethod = async function (this: AmazonPageWorker, ...args: any[]) {
try {
return await originalMethod.call(this, ...args); // 调用原有方法
} catch (error) {
this.channel.emit('error', { message: `发生未知错误:${error}` });
throw error;
}
};
// 返回装饰后的方法
return decoratedMethod;
}
/** /**
* AmazonPageWorkerImpl can run on background & sidepanel & popup, * AmazonPageWorkerImpl can run on background & sidepanel & popup,
* **can't** run on content script! * **can't** run on content script!
@ -27,7 +49,7 @@ class AmazonPageWorkerImpl implements AmazonPageWorker {
/** /**
* The signal to interrupt the current operation. * The signal to interrupt the current operation.
*/ */
private _interruptSignal = false; private _isCancel = false;
private async getCurrentTab(): Promise<Tabs.Tab> { private async getCurrentTab(): Promise<Tabs.Tab> {
const tab = await browser.tabs const tab = await browser.tabs
@ -49,10 +71,14 @@ class AmazonPageWorkerImpl implements AmazonPageWorker {
// #region Wait for the Next button to appear, indicating that the product items have finished loading // #region Wait for the Next button to appear, indicating that the product items have finished loading
await exec(tabId, async () => { await exec(tabId, async () => {
await new Promise((resolve) => setTimeout(resolve, 500 + ~~(500 * Math.random()))); await new Promise((resolve) => setTimeout(resolve, 500 + ~~(500 * Math.random())));
window.scrollBy(0, ~~(Math.random() * 500) + 500); while (true) {
while (!document.querySelector('.s-pagination-strip')) { const target = document.querySelector('.s-pagination-strip');
window.scrollBy(0, ~~(Math.random() * 500) + 500); window.scrollBy(0, ~~(Math.random() * 500) + 500);
await new Promise((resolve) => setTimeout(resolve, ~~(Math.random() * 50) + 50)); await new Promise((resolve) => setTimeout(resolve, ~~(Math.random() * 50) + 500));
if (target || document.readyState === 'complete') {
target?.scrollIntoView({ behavior: 'smooth', block: 'center' });
break;
}
} }
}); });
// #endregion // #endregion
@ -77,11 +103,11 @@ class AmazonPageWorkerImpl implements AmazonPageWorker {
// 处理商品以列表形式展示的情况 // 处理商品以列表形式展示的情况
case 'pattern-1': case 'pattern-1':
data = await exec(tabId, async () => { data = await exec(tabId, async () => {
const items = [ const items = Array.from(
...(document.querySelectorAll<HTMLDivElement>( document.querySelectorAll<HTMLDivElement>(
'.puisg-row:has(.a-section.a-spacing-small.a-spacing-top-small:not(.a-text-right))', '.puisg-row:has(.a-section.a-spacing-small.a-spacing-top-small:not(.a-text-right))',
) as unknown as HTMLDivElement[]), ),
].filter((e) => e.getClientRects().length > 0); ).filter((e) => e.getClientRects().length > 0);
const linkObjs = items.reduce<{ link: string; title: string; imageSrc: string }[]>( const linkObjs = items.reduce<{ link: string; title: string; imageSrc: string }[]>(
(objs, el) => { (objs, el) => {
const link = el.querySelector<HTMLAnchorElement>('a')?.href; const link = el.querySelector<HTMLAnchorElement>('a')?.href;
@ -100,11 +126,11 @@ class AmazonPageWorkerImpl implements AmazonPageWorker {
// 处理商品以二维图片格展示的情况 // 处理商品以二维图片格展示的情况
case 'pattern-2': case 'pattern-2':
data = await exec(tabId, async () => { data = await exec(tabId, async () => {
const items = [ const items = Array.from(
...(document.querySelectorAll<HTMLDivElement>( document.querySelectorAll<HTMLDivElement>(
'.puis-card-container:has(.a-section.a-spacing-small.puis-padding-left-small)', '.puis-card-container:has(.a-section.a-spacing-small.puis-padding-left-small)',
) as unknown as HTMLDivElement[]), ) as unknown as HTMLDivElement[],
].filter((e) => e.getClientRects().length > 0); ).filter((e) => e.getClientRects().length > 0);
const linkObjs = items.reduce<{ link: string; title: string; imageSrc: string }[]>( const linkObjs = items.reduce<{ link: string; title: string; imageSrc: string }[]>(
(objs, el) => { (objs, el) => {
const link = el.querySelector<HTMLAnchorElement>('a.a-link-normal')?.href; const link = el.querySelector<HTMLAnchorElement>('a.a-link-normal')?.href;
@ -134,7 +160,7 @@ class AmazonPageWorkerImpl implements AmazonPageWorker {
return false; return false;
} }
} else { } else {
throw new Error('Error: next page button not found'); return false;
} }
}); });
// #endregion // #endregion
@ -146,6 +172,7 @@ class AmazonPageWorkerImpl implements AmazonPageWorker {
return { data, hasNextPage }; return { data, hasNextPage };
} }
@withErrorHandling
public async doSearch(keywords: string): Promise<string> { public async doSearch(keywords: string): Promise<string> {
const url = new URL('https://www.amazon.com/s'); const url = new URL('https://www.amazon.com/s');
url.searchParams.append('k', keywords); url.searchParams.append('k', keywords);
@ -161,18 +188,20 @@ class AmazonPageWorkerImpl implements AmazonPageWorker {
return url.toString(); return url.toString();
} }
@withErrorHandling
public async wanderSearchPage(): Promise<void> { public async wanderSearchPage(): Promise<void> {
const tab = await this.getCurrentTab(); const tab = await this.getCurrentTab();
this._interruptSignal = false; this._isCancel = false;
const stop = async (_: unknown): Promise<void> => { const stop = this.channel.on('error', async (_: unknown): Promise<void> => {
this._interruptSignal = true; this._isCancel = true;
}; });
this.channel.on('error', stop);
let offset = 0; let offset = 0;
while (!this._interruptSignal) { while (!this._isCancel) {
const { hasNextPage, data } = await this.wanderSearchSinglePage(tab); const { hasNextPage, data } = await this.wanderSearchSinglePage(tab);
const keywords = new URL(tab.url!).searchParams.get('k')!;
const objs = data.map((r, i) => ({ const objs = data.map((r, i) => ({
...r, ...r,
keywords,
rank: offset + 1 + i, rank: offset + 1 + i,
createTime: new Date().toLocaleString(), createTime: new Date().toLocaleString(),
asin: /(?<=\/dp\/)[A-Z0-9]{10}/.exec(r.link as string)![0], asin: /(?<=\/dp\/)[A-Z0-9]{10}/.exec(r.link as string)![0],
@ -183,12 +212,14 @@ class AmazonPageWorkerImpl implements AmazonPageWorker {
break; break;
} }
} }
this._interruptSignal = false; this._isCancel = false;
this.channel.off('error', stop); this.channel.off('error', stop);
return new Promise((resolve) => setTimeout(resolve, 1000)); return new Promise((resolve) => setTimeout(resolve, 1000));
} }
@withErrorHandling
public async wanderDetailPage(entry: string): Promise<void> { public async wanderDetailPage(entry: string): Promise<void> {
//#region Initial Meta Info
const params = { asin: '', url: '' }; const params = { asin: '', url: '' };
if (entry.match(/^https?:\/\/www\.amazon\.com.*\/dp\/[A-Z0-9]{10}/)) { if (entry.match(/^https?:\/\/www\.amazon\.com.*\/dp\/[A-Z0-9]{10}/)) {
const [asin] = /\/\/dp\/[A-Z0-9]{10}/.exec(entry)!; const [asin] = /\/\/dp\/[A-Z0-9]{10}/.exec(entry)!;
@ -198,18 +229,30 @@ class AmazonPageWorkerImpl implements AmazonPageWorker {
params.asin = entry; params.asin = entry;
params.url = `https://www.amazon.com/dp/${entry}`; params.url = `https://www.amazon.com/dp/${entry}`;
} }
const tab = await this.createNewTab(params.url); let tab = await this.getCurrentTab();
if (!tab.url || !tab.url.startsWith('http')) {
tab = await this.createNewTab(params.url);
} else {
tab = await browser.tabs.update(tab.id, {
url: params.url,
});
}
//#endregion
//#region Await Production Introduction Element Loaded and Determine Page Pattern //#region Await Production Introduction Element Loaded and Determine Page Pattern
const pattern = await exec(tab.id!, async () => { await exec(tab.id!, async () => {
window.scrollBy(0, ~~(Math.random() * 500) + 500); while (true) {
let targetNode = document.querySelector('#prodDetails, #detailBulletsWrapper_feature_div');
while (!targetNode || document.readyState === 'loading') {
window.scrollBy(0, ~~(Math.random() * 500) + 500); window.scrollBy(0, ~~(Math.random() * 500) + 500);
await new Promise((resolve) => setTimeout(resolve, ~~(Math.random() * 50) + 50)); await new Promise((resolve) => setTimeout(resolve, ~~(Math.random() * 50) + 50));
targetNode = document.querySelector('#prodDetails, #detailBulletsWrapper_feature_div'); const targetNode = document.querySelector(
'#prodDetails:has(td), #detailBulletsWrapper_feature_div:has(li)',
);
if (targetNode && document.readyState !== 'loading') {
targetNode.scrollIntoView({ behavior: 'smooth', block: 'center' });
return targetNode.getAttribute('id') === 'prodDetails' ? 'pattern-1' : 'pattern-2';
}
} }
return targetNode.getAttribute('id') === 'prodDetails' ? 'pattern-1' : 'pattern-2';
}); });
await new Promise((resolve) => setTimeout(resolve, 3000)); // Wait 3 seconds.
//#endregion //#endregion
//#region Fetch Rating Info //#region Fetch Rating Info
const ratingInfo = await exec(tab.id!, async () => { const ratingInfo = await exec(tab.id!, async () => {
@ -237,55 +280,49 @@ class AmazonPageWorkerImpl implements AmazonPageWorker {
} }
//#endregion //#endregion
//#region Fetch Category Rank Info //#region Fetch Category Rank Info
let rawRankingText: string | null = null; let rawRankingText: string | null = await exec(tab.id!, async () => {
switch (pattern) { const xpathExps = [
case 'pattern-1': `//div[@id='detailBulletsWrapper_feature_div']//ul[.//li[contains(., 'Best Sellers Rank')]]//span[@class='a-list-item']`,
rawRankingText = await exec(tab.id!, async () => { `//div[@id='prodDetails']//table/tbody/tr[th[1][contains(text(), 'Best Sellers Rank')]]/td`,
const xpathExp = `//div[@id='prodDetails']//table/tbody/tr[th[1][contains(text(), 'Best Sellers Rank')]]/td`; `//div[@id='productDetails_db_sections']//table/tbody/tr[th[1][contains(text(), 'Best Sellers Rank')]]/td`,
const targetNode = document.evaluate( ];
xpathExp, for (const xpathExp of xpathExps) {
document, const targetNode = document.evaluate(
null, xpathExp,
XPathResult.FIRST_ORDERED_NODE_TYPE, document,
null, null,
).singleNodeValue as HTMLDivElement | null; XPathResult.FIRST_ORDERED_NODE_TYPE,
return targetNode?.innerText || null; null,
}); ).singleNodeValue as HTMLDivElement | null;
break; if (targetNode) {
case 'pattern-2': targetNode.scrollIntoView({ behavior: 'smooth', block: 'center' });
rawRankingText = await exec(tab.id!, async () => { return targetNode.innerText;
const xpathExp = `//div[@id='detailBulletsWrapper_feature_div']//ul[.//li[contains(., 'Best Sellers Rank')]]//span[@class='a-list-item']`; }
const targetNode = document.evaluate( }
xpathExp, return null;
document, });
null,
XPathResult.FIRST_ORDERED_NODE_TYPE,
null,
).singleNodeValue as HTMLDivElement | null;
return targetNode?.innerText || null;
});
break;
}
if (rawRankingText) { if (rawRankingText) {
let statement = /#[0-9,]+\sin\s\S[\s\w&\(\)]+/.exec(rawRankingText)![0]!; const info: Pick<AmazonDetailItem, 'category1' | 'category2'> = {};
const category1Name = /(?<=in\s).+(?=\s\(See)/.exec(statement)?.[0] || null; let statement = /#[0-9,]+\sin\s\S[\s\w&\(\)]+/.exec(rawRankingText)?.[0];
const category1Ranking = if (statement) {
Number(/(?<=#)[0-9,]+/.exec(statement)?.[0].replace(',', '')) || null; const name = /(?<=in\s).+(?=\s\(See)/.exec(statement)?.[0] || null;
const rank = Number(/(?<=#)[0-9,]+/.exec(statement)?.[0].replace(',', '')) || null;
rawRankingText = rawRankingText.replace(statement, ''); if (name && rank) {
statement = /#[0-9,]+\sin\s\S[\s\w&\(\)]+/.exec(rawRankingText)![0]!; info['category1'] = { name, rank };
const category2Name = /(?<=in\s).+/.exec(statement)?.[0].replace(/[\s]+$/, '') || null; }
const category2Ranking = rawRankingText = rawRankingText.replace(statement, '');
Number(/(?<=#)[0-9,]+/.exec(statement)?.[0].replace(',', '')) || null; }
statement = /#[0-9,]+\sin\s\S[\s\w&\(\)]+/.exec(rawRankingText)?.[0];
if (statement) {
const name = /(?<=in\s).+/.exec(statement)?.[0].replace(/[\s]+$/, '') || null;
const rank = Number(/(?<=#)[0-9,]+/.exec(statement)?.[0].replace(',', '')) || null;
if (name && rank) {
info['category2'] = { name, rank };
}
}
this.channel.emit('item-category-rank-collected', { this.channel.emit('item-category-rank-collected', {
asin: params.asin, asin: params.asin,
category1: ![category1Name, category1Ranking].includes(null) ...info,
? { name: category1Name!, rank: category1Ranking! }
: undefined,
category2: ![category2Name, category2Ranking].includes(null)
? { name: category2Name!, rank: category2Ranking! }
: undefined,
}); });
} }
//#endregion //#endregion
@ -295,8 +332,8 @@ class AmazonPageWorkerImpl implements AmazonPageWorker {
...(document.querySelectorAll('.imageThumbnail img') as unknown as HTMLImageElement[]), ...(document.querySelectorAll('.imageThumbnail img') as unknown as HTMLImageElement[]),
].map((e) => e.src); ].map((e) => e.src);
//#region process more images 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');
const overlay = document.querySelector<HTMLDivElement>('.overlayRestOfImages')!; if (overlay) {
if (document.querySelector<HTMLDivElement>('#ivThumbs')!.getClientRects().length === 0) { if (document.querySelector<HTMLDivElement>('#ivThumbs')!.getClientRects().length === 0) {
overlay.click(); overlay.click();
await new Promise((resolve) => setTimeout(resolve, 1000)); await new Promise((resolve) => setTimeout(resolve, 1000));
@ -310,6 +347,10 @@ class AmazonPageWorkerImpl implements AmazonPageWorker {
const [url] = /(?<=url\(").+(?=")/.exec(s)!; const [url] = /(?<=url\(").+(?=")/.exec(s)!;
return url; return url;
}); });
await new Promise((resolve) => setTimeout(resolve, 1000));
document
.querySelector<HTMLButtonElement>(".a-popover button[data-action='a-popover-close']")
?.click();
} }
//#endregion //#endregion
//#region post-process image urls //#region post-process image urls
@ -331,11 +372,11 @@ class AmazonPageWorkerImpl implements AmazonPageWorker {
imageUrls, imageUrls,
}); });
//#endregion //#endregion
await browser.tabs.remove(tab.id!); await new Promise((resolve) => setTimeout(resolve, 2000)); // Wait 2 seconds.
} }
public async stop(): Promise<void> { public async stop(): Promise<void> {
this._interruptSignal = true; this._isCancel = true;
} }
} }

View File

@ -25,7 +25,7 @@ interface AmazonPageWorkerEvents {
/** /**
* The event is fired when worker collected links to items on the Amazon search page. * The event is fired when worker collected links to items on the Amazon search page.
*/ */
['item-links-collected']: { objs: Omit<AmazonSearchItem, 'keywords'>[] }; ['item-links-collected']: { objs: AmazonSearchItem[] };
/** /**
* The event is fired when worker collected goods' rating on the Amazon detail page. * The event is fired when worker collected goods' rating on the Amazon detail page.

View File

@ -1,7 +1,9 @@
import { useWebExtensionStorage } from '~/composables/useWebExtensionStorage'; import { useWebExtensionStorage } from '~/composables/useWebExtensionStorage';
import type { AmazonDetailItem, AmazonItem, AmazonSearchItem } from './page-worker/types'; import type { AmazonDetailItem, AmazonItem, AmazonSearchItem } from './page-worker/types';
export const keywords = useWebExtensionStorage<string>('keywords', ''); export const keywordsList = useWebExtensionStorage<string[]>('keywordsList', ['']);
export const asinInputText = useWebExtensionStorage<string>('asinInputText', '');
export const searchItems = useWebExtensionStorage<AmazonSearchItem[]>('itemList', []); export const searchItems = useWebExtensionStorage<AmazonSearchItem[]>('itemList', []);

View File

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

View File

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

View File

@ -1,26 +1,18 @@
<script setup lang="ts"> <script setup lang="ts">
import type { FormRules, UploadOnChange } from 'naive-ui'; import type { FormItemRule, UploadOnChange } from 'naive-ui';
import pageWorkerFactory from '~/logic/page-worker'; import pageWorkerFactory from '~/logic/page-worker';
import { detailItems } from '~/logic/storage'; import { asinInputText, detailItems } from '~/logic/storage';
const message = useMessage(); const message = useMessage();
const formItem = reactive({ asin: '' }); const formItemRef = useTemplateRef('detail-form-item');
const formRef = useTemplateRef('detail-form'); const formItemRule: FormItemRule = {
const formRules: FormRules = { required: true,
asin: [ trigger: ['submit', 'blur'],
{ message: '请输入格式正确的ASIN',
required: true, validator: () => {
trigger: ['submit', 'blur'], return asinInputText.value.match(/^[A-Z0-9]{10}((\n|\s|,|;)[A-Z0-9]{10})*\n?$/g) !== null;
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< const timelines = ref<
@ -32,20 +24,23 @@ const timelines = ref<
}[] }[]
>([]); >([]);
const running = ref(false);
const worker = pageWorkerFactory.useAmazonPageWorker(); // Page Worker const worker = pageWorkerFactory.useAmazonPageWorker(); // Page Worker
worker.channel.on('error', ({ message: msg }) => { worker.channel.on('error', ({ message: msg }) => {
timelines.value.push({ timelines.value.push({
type: 'error', type: 'error',
title: '错误', title: '错误发生',
time: new Date().toLocaleString(), time: new Date().toLocaleString(),
content: msg, content: msg,
}); });
message.error(msg); message.error(msg);
running.value = false;
}); });
worker.channel.on('item-rating-collected', (ev) => { worker.channel.on('item-rating-collected', (ev) => {
timelines.value.push({ timelines.value.push({
type: 'success', type: 'success',
title: `商品: ${ev.asin}`, title: `商品${ev.asin}评价信息`,
time: new Date().toLocaleString(), time: new Date().toLocaleString(),
content: `评分: ${ev.rating};评价数:${ev.ratingCount}`, content: `评分: ${ev.rating};评价数:${ev.ratingCount}`,
}); });
@ -54,7 +49,7 @@ worker.channel.on('item-rating-collected', (ev) => {
worker.channel.on('item-category-rank-collected', (ev) => { worker.channel.on('item-category-rank-collected', (ev) => {
timelines.value.push({ timelines.value.push({
type: 'success', type: 'success',
title: `商品: ${ev.asin}`, title: `商品${ev.asin}分类排名`,
time: new Date().toLocaleString(), time: new Date().toLocaleString(),
content: [ content: [
ev.category1 ? `#${ev.category1.rank} in ${ev.category1.name}` : '', ev.category1 ? `#${ev.category1.rank} in ${ev.category1.name}` : '',
@ -66,7 +61,7 @@ worker.channel.on('item-category-rank-collected', (ev) => {
worker.channel.on('item-images-collected', (ev) => { worker.channel.on('item-images-collected', (ev) => {
timelines.value.push({ timelines.value.push({
type: 'success', type: 'success',
title: `商品: ${ev.asin}`, title: `商品${ev.asin}图像`,
time: new Date().toLocaleString(), time: new Date().toLocaleString(),
content: `图片数: ${ev.imageUrls.length}`, content: `图片数: ${ev.imageUrls.length}`,
}); });
@ -81,7 +76,7 @@ const handleImportAsin: UploadOnChange = ({ fileList }) => {
reader.onload = (e) => { reader.onload = (e) => {
const content = e.target?.result; const content = e.target?.result;
if (typeof content === 'string') { if (typeof content === 'string') {
formItem.asin = content; asinInputText.value = content;
} }
}; };
reader.readAsText(file.file, 'utf-8'); reader.readAsText(file.file, 'utf-8');
@ -90,7 +85,7 @@ const handleImportAsin: UploadOnChange = ({ fileList }) => {
}; };
const handleExportAsin = () => { const handleExportAsin = () => {
const blob = new Blob([formItem.asin], { type: 'text/plain' }); const blob = new Blob([asinInputText.value], { type: 'text/plain' });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const filename = `asin-${new Date().toISOString()}.txt`; const filename = `asin-${new Date().toISOString()}.txt`;
const link = document.createElement('a'); const link = document.createElement('a');
@ -103,33 +98,39 @@ const handleExportAsin = () => {
message.info('导出完成'); message.info('导出完成');
}; };
const handleGetInfo = () => { const handleFetchInfoFromPage = () => {
formRef.value?.validate(async (errors) => { const runTask = async () => {
if (errors) { const asinList = asinInputText.value.split(/\n|\s|,|;/).filter((item) => item.length > 0);
message.error('格式错误,请检查输入'); running.value = true;
return; timelines.value = [
} {
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', type: 'info',
title: '结束', title: '开始',
time: new Date().toLocaleString(), time: new Date().toLocaleString(),
content: '数据采集完成', content: '开始数据采集',
}); },
];
while (asinList.length > 0) {
const asin = asinList.shift()!;
await worker.wanderDetailPage(asin);
asinInputText.value = asinList.join('\n'); // Update Input Text
} }
timelines.value.push({
type: 'info',
title: '结束',
time: new Date().toLocaleString(),
content: '数据采集完成',
});
running.value = false;
};
formItemRef.value?.validate({
callback: (errors) => {
if (errors) {
return;
} else {
runTask();
}
},
}); });
}; };
</script> </script>
@ -138,10 +139,10 @@ const handleGetInfo = () => {
<div class="detail-page-worker"> <div class="detail-page-worker">
<header-menu /> <header-menu />
<div class="title"> <div class="title">
<n-icon :size="60"> <mdi-cat style="color: black" /> </n-icon> <mdi-cat style="color: black; font-size: 60px" />
<h1 style="font-size: 30px; color: black">Detail Page</h1> <h1 style="font-size: 30px; color: black">Detail Page</h1>
</div> </div>
<div class="interative-section"> <div v-if="!running" class="interative-section">
<n-space> <n-space>
<n-upload @change="handleImportAsin" accept=".txt" :max="1"> <n-upload @change="handleImportAsin" accept=".txt" :max="1">
<n-button round size="small"> <n-button round size="small">
@ -158,23 +159,29 @@ const handleGetInfo = () => {
导出 导出
</n-button> </n-button>
</n-space> </n-space>
<n-form :rules="formRules" :model="formItem" ref="detail-form" label-placement="left"> <n-form-item
<n-form-item style="padding-top: 0px" path="asin"> ref="detail-form-item"
<n-input label-placement="left"
v-model:value="formItem.asin" :rule="formItemRule"
placeholder="输入ASINs" style="padding-top: 0px"
type="textarea" >
size="large" <n-input
/> v-model:value="asinInputText"
</n-form-item> placeholder="输入ASINs"
</n-form> type="textarea"
<n-button class="start-button" round size="large" type="primary" @click="handleGetInfo"> size="large"
/>
</n-form-item>
<n-button round size="large" type="primary" @click="handleFetchInfoFromPage">
<template #icon> <template #icon>
<ant-design-thunderbolt-outlined /> <ant-design-thunderbolt-outlined />
</template> </template>
开始 开始
</n-button> </n-button>
</div> </div>
<div v-else class="running-tip-section">
<n-alert title="Warning" type="warning"> 警告在插件运行期间请勿与浏览器交互 </n-alert>
</div>
<progress-report class="progress-report" :timelines="timelines" /> <progress-report class="progress-report" :timelines="timelines" />
</div> </div>
</template> </template>
@ -189,6 +196,7 @@ const handleGetInfo = () => {
.title { .title {
margin: 20px 0 30px 0; margin: 20px 0 30px 0;
font-size: 60px;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
@ -200,13 +208,22 @@ const handleGetInfo = () => {
.interative-section { .interative-section {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding: 15px;
align-items: stretch;
justify-content: center;
width: 85%; width: 85%;
padding: 15px 15px;
border-radius: 10px; border-radius: 10px;
border: 1px #00000020 dashed; border: 1px #00000020 dashed;
margin: 0 0 10px 0; margin: 0 0 10px 0;
} }
.running-tip-section {
margin: 0 0 10px 0;
height: 100px;
border-radius: 10px;
cursor: wait;
}
.progress-report { .progress-report {
margin-top: 20px; margin-top: 20px;
width: 95%; width: 95%;

View File

@ -1,7 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { keywords } from '~/logic/storage'; import { keywordsList } from '~/logic/storage';
import pageWorker from '~/logic/page-worker'; import pageWorker from '~/logic/page-worker';
import type { AmazonSearchItem } from '~/logic/page-worker/types';
import { NButton } from 'naive-ui'; import { NButton } from 'naive-ui';
import { searchItems } from '~/logic/storage'; import { searchItems } from '~/logic/storage';
@ -11,12 +10,13 @@ const worker = pageWorker.useAmazonPageWorker();
worker.channel.on('error', ({ message: msg }) => { worker.channel.on('error', ({ message: msg }) => {
timelines.value.push({ timelines.value.push({
type: 'error', type: 'error',
title: '错误', title: '错误发生',
time: new Date().toLocaleString(), time: new Date().toLocaleString(),
content: msg, content: msg,
}); });
message.error(msg); message.error(msg);
worker.stop(); worker.stop();
running.value = false;
}); });
worker.channel.on('item-links-collected', ({ objs }) => { worker.channel.on('item-links-collected', ({ objs }) => {
timelines.value.push({ timelines.value.push({
@ -25,16 +25,10 @@ worker.channel.on('item-links-collected', ({ objs }) => {
time: new Date().toLocaleString(), time: new Date().toLocaleString(),
content: `成功采集到 ${objs.length} 条数据`, content: `成功采集到 ${objs.length} 条数据`,
}); });
const addedRows = objs.map<AmazonSearchItem>((v) => { searchItems.value = searchItems.value.concat(objs); // Add records
return {
...v,
keywords: keywords.value,
};
});
searchItems.value = searchItems.value.concat(addedRows);
}); });
//#endregion //#endregion
const workerRunning = ref(false); const running = ref(false);
const timelines = ref< const timelines = ref<
{ {
@ -45,36 +39,28 @@ const timelines = ref<
}[] }[]
>([]); >([]);
const onCollectStart = async () => { const handleFetchInfoFromPage = async () => {
workerRunning.value = true; running.value = true;
timelines.value = [ timelines.value = [];
{ for (const keywords of keywordsList.value.filter((k) => k.trim() !== '')) {
timelines.value.push({
type: 'info', type: 'info',
title: '开始', title: '开始',
time: new Date().toLocaleString(), time: new Date().toLocaleString(),
content: '开始数据采集', content: `开始关键词:${keywords} 数据采集`,
}, });
]; //#region start page worker
if (keywords.value.trim() === '') { await worker.doSearch(keywords);
return; await worker.wanderSearchPage();
//#endregion
timelines.value.push({
type: 'info',
title: '结束',
time: new Date().toLocaleString(),
content: `关键词: ${keywords} 数据采集完成`,
});
} }
//#region start page worker running.value = false;
await worker.doSearch(keywords.value);
await worker.wanderSearchPage();
//#endregion
timelines.value.push({
type: 'info',
title: '结束',
time: new Date().toLocaleString(),
content: '数据采集完成',
});
workerRunning.value = false;
};
const onCollectStop = async () => {
workerRunning.value = false;
worker.stop();
message.info('停止收集');
}; };
</script> </script>
@ -85,39 +71,28 @@ const onCollectStop = async () => {
<mdi-cat style="font-size: 60px; color: black" /> <mdi-cat style="font-size: 60px; color: black" />
<h1>Search Page</h1> <h1>Search Page</h1>
</n-space> </n-space>
<div class="interactive-section"> <div v-if="!running" class="interactive-section">
<n-space> <n-dynamic-input
<n-input v-model:value="keywordsList"
:disabled="workerRunning" :min="1"
v-model:value="keywords" :max="10"
class="search-input-box" class="search-input-box"
autosize autosize
size="large" size="large"
round round
placeholder="请输入关键词采集信息" placeholder="请输入关键词采集信息"
> />
<template #prefix> <n-button type="primary" round size="large" @click="handleFetchInfoFromPage()">
<n-icon size="20"> <template #icon>
<ion-search /> <n-icon>
</n-icon> <ant-design-thunderbolt-outlined />
</template> </n-icon>
</n-input> </template>
<n-button 开始
type="primary" </n-button>
round </div>
size="large" <div v-else class="running-tip-section">
@click="!workerRunning ? onCollectStart() : onCollectStop()" <n-alert title="Warning" type="warning"> 警告在插件运行期间请勿与浏览器交互 </n-alert>
>
<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>
<div style="height: 10px"></div> <div style="height: 10px"></div>
<progress-report class="progress-report" :timelines="timelines" /> <progress-report class="progress-report" :timelines="timelines" />
@ -134,19 +109,30 @@ const onCollectStop = async () => {
gap: 20px; gap: 20px;
.app-title { .app-title {
margin-top: 60px; margin-top: 20px;
} }
.interactive-section { .interactive-section {
padding: 10px 15px;
border-radius: 10px; border-radius: 10px;
border: 1px #00000020 dashed; width: 80%;
outline: 1px #00000020 dashed;
display: flex;
flex-direction: column;
justify-content: center;
align-items: stretch;
gap: 15px;
padding: 15px 25px;
.search-input-box { .search-input-box {
min-width: 240px; min-width: 240px;
} }
} }
.running-tip-section {
border-radius: 10px;
cursor: wait;
}
.progress-report { .progress-report {
width: 90%; width: 90%;
} }

View File

@ -17,7 +17,7 @@ const currentComponent = computed(() => {
const tab = tabs.find((tab) => tab.name === selectedTab.value); const tab = tabs.find((tab) => tab.name === selectedTab.value);
return tab ? tab.component : null; return tab ? tab.component : null;
}); });
const showHeader = ref(false); const showHeader = ref(true);
</script> </script>
<template> <template>