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"
:time="item.time"
>
{{ item.content }}
<div v-for="line in item.content.split('\n')">{{ line }}</div>
</n-timeline-item>
</n-timeline>
<n-empty v-else size="large">

View File

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

View File

@ -76,6 +76,9 @@ export function exportToXLSX(
const worksheet = utils.json_to_sheet(rows, {
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();
utils.book_append_sheet(workbook, worksheet, 'Sheet1');
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();
if (ret?.error) {
console.error('注入脚本时发生错误', ret.error);
throw new Error('注入脚本时发生错误');
}
return ret?.result as T | null;
}

View File

@ -1,8 +1,30 @@
import Emittery from 'emittery';
import type { AmazonPageWorker, AmazonPageWorkerEvents } from './types';
import type { AmazonDetailItem, AmazonPageWorker, AmazonPageWorkerEvents } from './types';
import type { Tabs } from 'webextension-polyfill';
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,
* **can't** run on content script!
@ -27,7 +49,7 @@ class AmazonPageWorkerImpl implements AmazonPageWorker {
/**
* The signal to interrupt the current operation.
*/
private _interruptSignal = false;
private _isCancel = false;
private async getCurrentTab(): Promise<Tabs.Tab> {
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
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')) {
while (true) {
const target = document.querySelector('.s-pagination-strip');
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
@ -77,11 +103,11 @@ class AmazonPageWorkerImpl implements AmazonPageWorker {
// 处理商品以列表形式展示的情况
case 'pattern-1':
data = await exec(tabId, async () => {
const items = [
...(document.querySelectorAll<HTMLDivElement>(
const items = Array.from(
document.querySelectorAll<HTMLDivElement>(
'.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 }[]>(
(objs, el) => {
const link = el.querySelector<HTMLAnchorElement>('a')?.href;
@ -100,11 +126,11 @@ class AmazonPageWorkerImpl implements AmazonPageWorker {
// 处理商品以二维图片格展示的情况
case 'pattern-2':
data = await exec(tabId, async () => {
const items = [
...(document.querySelectorAll<HTMLDivElement>(
const items = Array.from(
document.querySelectorAll<HTMLDivElement>(
'.puis-card-container:has(.a-section.a-spacing-small.puis-padding-left-small)',
) as unknown as HTMLDivElement[]),
].filter((e) => e.getClientRects().length > 0);
) as unknown as HTMLDivElement[],
).filter((e) => e.getClientRects().length > 0);
const linkObjs = items.reduce<{ link: string; title: string; imageSrc: string }[]>(
(objs, el) => {
const link = el.querySelector<HTMLAnchorElement>('a.a-link-normal')?.href;
@ -134,7 +160,7 @@ class AmazonPageWorkerImpl implements AmazonPageWorker {
return false;
}
} else {
throw new Error('Error: next page button not found');
return false;
}
});
// #endregion
@ -146,6 +172,7 @@ class AmazonPageWorkerImpl implements AmazonPageWorker {
return { data, hasNextPage };
}
@withErrorHandling
public async doSearch(keywords: string): Promise<string> {
const url = new URL('https://www.amazon.com/s');
url.searchParams.append('k', keywords);
@ -161,18 +188,20 @@ class AmazonPageWorkerImpl implements AmazonPageWorker {
return url.toString();
}
@withErrorHandling
public async wanderSearchPage(): Promise<void> {
const tab = await this.getCurrentTab();
this._interruptSignal = false;
const stop = async (_: unknown): Promise<void> => {
this._interruptSignal = true;
};
this.channel.on('error', stop);
this._isCancel = false;
const stop = this.channel.on('error', async (_: unknown): Promise<void> => {
this._isCancel = true;
});
let offset = 0;
while (!this._interruptSignal) {
while (!this._isCancel) {
const { hasNextPage, data } = await this.wanderSearchSinglePage(tab);
const keywords = new URL(tab.url!).searchParams.get('k')!;
const objs = data.map((r, i) => ({
...r,
keywords,
rank: offset + 1 + i,
createTime: new Date().toLocaleString(),
asin: /(?<=\/dp\/)[A-Z0-9]{10}/.exec(r.link as string)![0],
@ -183,12 +212,14 @@ class AmazonPageWorkerImpl implements AmazonPageWorker {
break;
}
}
this._interruptSignal = false;
this._isCancel = false;
this.channel.off('error', stop);
return new Promise((resolve) => setTimeout(resolve, 1000));
}
@withErrorHandling
public async wanderDetailPage(entry: string): Promise<void> {
//#region Initial Meta Info
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)!;
@ -198,18 +229,30 @@ class AmazonPageWorkerImpl implements AmazonPageWorker {
params.asin = 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
const pattern = await exec(tab.id!, async () => {
window.scrollBy(0, ~~(Math.random() * 500) + 500);
let targetNode = document.querySelector('#prodDetails, #detailBulletsWrapper_feature_div');
while (!targetNode || document.readyState === 'loading') {
await exec(tab.id!, async () => {
while (true) {
window.scrollBy(0, ~~(Math.random() * 500) + 500);
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
//#region Fetch Rating Info
const ratingInfo = await exec(tab.id!, async () => {
@ -237,55 +280,49 @@ class AmazonPageWorkerImpl implements AmazonPageWorker {
}
//#endregion
//#region Fetch Category Rank Info
let rawRankingText: string | null = null;
switch (pattern) {
case 'pattern-1':
rawRankingText = await exec(tab.id!, async () => {
const xpathExp = `//div[@id='prodDetails']//table/tbody/tr[th[1][contains(text(), 'Best Sellers Rank')]]/td`;
const targetNode = document.evaluate(
xpathExp,
document,
null,
XPathResult.FIRST_ORDERED_NODE_TYPE,
null,
).singleNodeValue as HTMLDivElement | null;
return targetNode?.innerText || null;
});
break;
case 'pattern-2':
rawRankingText = await exec(tab.id!, async () => {
const xpathExp = `//div[@id='detailBulletsWrapper_feature_div']//ul[.//li[contains(., 'Best Sellers Rank')]]//span[@class='a-list-item']`;
const targetNode = document.evaluate(
xpathExp,
document,
null,
XPathResult.FIRST_ORDERED_NODE_TYPE,
null,
).singleNodeValue as HTMLDivElement | null;
return targetNode?.innerText || null;
});
break;
}
let rawRankingText: string | null = await exec(tab.id!, async () => {
const xpathExps = [
`//div[@id='detailBulletsWrapper_feature_div']//ul[.//li[contains(., 'Best Sellers Rank')]]//span[@class='a-list-item']`,
`//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`,
];
for (const xpathExp of xpathExps) {
const targetNode = document.evaluate(
xpathExp,
document,
null,
XPathResult.FIRST_ORDERED_NODE_TYPE,
null,
).singleNodeValue as HTMLDivElement | null;
if (targetNode) {
targetNode.scrollIntoView({ behavior: 'smooth', block: 'center' });
return targetNode.innerText;
}
}
return null;
});
if (rawRankingText) {
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(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(statement)?.[0].replace(',', '')) || null;
const info: Pick<AmazonDetailItem, 'category1' | 'category2'> = {};
let statement = /#[0-9,]+\sin\s\S[\s\w&\(\)]+/.exec(rawRankingText)?.[0];
if (statement) {
const name = /(?<=in\s).+(?=\s\(See)/.exec(statement)?.[0] || null;
const rank = Number(/(?<=#)[0-9,]+/.exec(statement)?.[0].replace(',', '')) || null;
if (name && rank) {
info['category1'] = { name, rank };
}
rawRankingText = rawRankingText.replace(statement, '');
}
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', {
asin: params.asin,
category1: ![category1Name, category1Ranking].includes(null)
? { name: category1Name!, rank: category1Ranking! }
: undefined,
category2: ![category2Name, category2Ranking].includes(null)
? { name: category2Name!, rank: category2Ranking! }
: undefined,
...info,
});
}
//#endregion
@ -295,8 +332,8 @@ class AmazonPageWorkerImpl implements AmazonPageWorker {
...(document.querySelectorAll('.imageThumbnail img') as unknown as HTMLImageElement[]),
].map((e) => e.src);
//#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) {
overlay.click();
await new Promise((resolve) => setTimeout(resolve, 1000));
@ -310,6 +347,10 @@ class AmazonPageWorkerImpl implements AmazonPageWorker {
const [url] = /(?<=url\(").+(?=")/.exec(s)!;
return url;
});
await new Promise((resolve) => setTimeout(resolve, 1000));
document
.querySelector<HTMLButtonElement>(".a-popover button[data-action='a-popover-close']")
?.click();
}
//#endregion
//#region post-process image urls
@ -331,11 +372,11 @@ class AmazonPageWorkerImpl implements AmazonPageWorker {
imageUrls,
});
//#endregion
await browser.tabs.remove(tab.id!);
await new Promise((resolve) => setTimeout(resolve, 2000)); // Wait 2 seconds.
}
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.
*/
['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.

View File

@ -1,7 +1,9 @@
import { useWebExtensionStorage } from '~/composables/useWebExtensionStorage';
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', []);

View File

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

View File

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

View File

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

View File

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

View File

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