mirror of
https://github.com/primedigitaltech/azon_seeker.git
synced 2026-01-19 13:13:22 +08:00
Update UI & Worker
This commit is contained in:
parent
f68b72fbb6
commit
804c2f60c6
@ -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">
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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`;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
2
src/logic/page-worker/types.d.ts
vendored
2
src/logic/page-worker/types.d.ts
vendored
@ -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.
|
||||
|
||||
@ -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', []);
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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%;
|
||||
|
||||
@ -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%;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user