Update UI & Worker

This commit is contained in:
johnathan 2025-05-23 09:44:35 +08:00
parent 3734c83d21
commit a84f148743
9 changed files with 334 additions and 100 deletions

View File

@ -33,12 +33,14 @@ const props = defineProps<{ model: AmazonDetailItem }>();
{{ link }}
</div>
</n-descriptions-item>
<n-descriptions-item label="评论" :span="2">
<n-descriptions-item v-if="props.model.topReviews" label="精选评论" :span="4">
<div v-for="review in props.model.topReviews" style="margin-bottom: 5px">
<h5 style="margin: 0">{{ review.username }}:</h5>
<h3 style="margin: 0">{{ review.username }}: {{ review.title }}</h3>
<div style="color: gray; font-size: smaller">{{ review.rating }}</div>
<div v-for="paragraph in review.content.split('\n')">
{{ paragraph }}
</div>
<div style="color: gray; font-size: smaller">{{ review.dateInfo }}</div>
</div>
</n-descriptions-item>
</n-descriptions>

View File

@ -24,7 +24,7 @@ const filterFormItems = computed(() => {
params: {
options: [
...records.reduce((o, c) => {
o.add(c.keywords);
c.keywords && o.add(c.keywords);
return o;
}, new Set<string>()),
].map((opt) => ({
@ -67,6 +67,11 @@ const columns: (TableColumn<AmazonItem> & { hidden?: boolean })[] = [
key: 'rank',
minWidth: 60,
},
{
title: 'ASIN',
key: 'asin',
minWidth: 130,
},
{
title: '标题',
key: 'title',
@ -75,9 +80,9 @@ const columns: (TableColumn<AmazonItem> & { hidden?: boolean })[] = [
},
},
{
title: 'ASIN',
key: 'asin',
minWidth: 130,
title: '价格',
key: 'price',
minWidth: 100,
},
{
title: '封面图',
@ -141,6 +146,12 @@ const extraHeaders: Header[] = [
formatOutputValue: (val?: string[]) => val?.join(';'),
parseImportValue: (val?: string) => val?.split(';'),
},
{
prop: 'topReviews',
label: '精选评论',
formatOutputValue: (val?: Record<string, any>[]) => JSON.stringify(val),
parseImportValue: (val?: string) => val && JSON.parse(val),
},
];
const filterItemData = (data: AmazonItem[]): AmazonItem[] => {
@ -148,7 +159,7 @@ const filterItemData = (data: AmazonItem[]): AmazonItem[] => {
if (search.trim() !== '') {
data = data.filter((r) => {
return [r.title, r.asin, r.keywords].some((field) =>
field.toLowerCase().includes(search.toLowerCase()),
field?.toLowerCase().includes(search.toLowerCase()),
);
});
}

View File

@ -2,7 +2,11 @@ import Emittery from 'emittery';
import type { AmazonDetailItem, AmazonPageWorker, AmazonPageWorkerEvents } from './types';
import type { Tabs } from 'webextension-polyfill';
import { withErrorHandling } from '../error-handler';
import { AmazonDetailPageInjector, AmazonSearchPageInjector } from '../web-injectors';
import {
AmazonDetailPageInjector,
AmazonReviewPageInjector,
AmazonSearchPageInjector,
} from '../web-injectors';
/**
* AmazonPageWorkerImpl can run on background & sidepanel & popup,
@ -75,7 +79,7 @@ class AmazonPageWorkerImpl implements AmazonPageWorker {
}
const currentUrl = new URL(tab.url!);
if (currentUrl.hostname !== url.hostname || currentUrl.searchParams.get('k') !== keywords) {
await browser.tabs.update(tab.id, { url: url.toString() });
tab = await browser.tabs.update(tab.id, { url: url.toString(), active: true });
await new Promise<void>((resolve) => setTimeout(resolve, 1000));
}
return url.toString();
@ -127,10 +131,18 @@ class AmazonPageWorkerImpl implements AmazonPageWorker {
}
const injector = new AmazonDetailPageInjector(tab);
//#endregion
//#region Await Production Introduction Element Loaded and Determine Page Pattern
//#region Await Production Introduction Element Loaded
await injector.waitForPageLoaded();
await new Promise((resolve) => setTimeout(resolve, 3000)); // Wait 3 seconds.
//#endregion
//#region Fetch Base Info
const baseInfo = await injector.getBaseInfo();
this.channel.emit('item-base-info-collected', {
asin: params.asin,
title: baseInfo.title,
price: baseInfo.price,
});
//#endregion
//#region Fetch Rating Info
const ratingInfo = await injector.getRatingInfo();
if (ratingInfo && (ratingInfo.rating !== 0 || ratingInfo.ratingCount !== 0)) {
@ -181,11 +193,28 @@ class AmazonPageWorkerImpl implements AmazonPageWorker {
reviews.length > 0 &&
this.channel.emit('item-top-reviews-collected', {
asin: params.asin,
topReviews: reviews.map((r) => ({ asin: params.asin, ...r })),
topReviews: reviews,
});
//#endregion
}
@withErrorHandling
public async wanderReviewPage(asin: string) {
const baseUrl = `https://www.amazon.com/product-reviews/${asin}/ref=cm_cr_dp_d_show_all_btm?ie=UTF8&reviewerType=all_reviews`;
const tab = await this.createNewTab(baseUrl);
const injector = new AmazonReviewPageInjector(tab);
while (true) {
await injector.waitForPageLoad();
const reviews = await injector.getSinglePageReviews();
reviews.length > 0 && this.channel.emit('item-review-collected', { asin, reviews });
const hasNextPage = await injector.jumpToNextPageIfExist();
if (!hasNextPage) {
break;
}
}
setTimeout(() => browser.tabs.remove(tab.id!), 1000);
}
public async runSearchPageTask(
keywordsList: string[],
progress?: (remains: string[]) => Promise<void>,
@ -221,17 +250,29 @@ class AmazonPageWorkerImpl implements AmazonPageWorker {
unsubscribe();
}
public async runReviewPageTask(
asins: string[],
progress?: (remains: string[]) => Promise<void>,
): Promise<void> {
let remains = [...asins];
while (remains.length > 0) {
const asin = remains.shift()!;
await this.wanderReviewPage(asin);
progress && progress(remains);
}
}
public async stop(): Promise<void> {
this._controlChannel.emit('interrupt');
}
}
class PageWorkerFactory {
class PageWorker {
public useAmazonPageWorker(): AmazonPageWorker {
return AmazonPageWorkerImpl.getInstance();
}
}
const pageWorkerFactory = new PageWorkerFactory();
const pageWorker = new PageWorker();
export default pageWorkerFactory;
export default pageWorker;

View File

@ -8,12 +8,15 @@ type AmazonSearchItem = {
title: string;
asin: string;
rank: number;
price?: string;
imageSrc: string;
createTime: string;
};
type AmazonDetailItem = {
asin: string;
title: string;
price?: string;
rating?: number;
ratingCount?: number;
category1?: { name: string; rank: number };
@ -23,7 +26,7 @@ type AmazonDetailItem = {
};
type AmazonReview = {
asin: string;
id: string;
username: string;
title: string;
rating: string;
@ -31,7 +34,9 @@ type AmazonReview = {
content: string;
};
type AmazonItem = AmazonSearchItem & Partial<AmazonDetailItem> & { hasDetail: boolean };
type AmazonItem = Pick<AmazonSearchItem, 'asin'> &
Partial<AmazonSearchItem> &
Partial<AmazonDetailItem> & { hasDetail: boolean };
interface AmazonPageWorkerEvents {
/**
@ -39,6 +44,11 @@ interface AmazonPageWorkerEvents {
*/
['item-links-collected']: { objs: AmazonSearchItem[] };
/**
* The event is fired when worker collected goods' base info on the Amazon detail page.
*/
['item-base-info-collected']: Pick<AmazonDetailItem, 'asin' | 'title' | 'price'>;
/**
* The event is fired when worker collected goods' rating on the Amazon detail page.
*/
@ -55,12 +65,17 @@ interface AmazonPageWorkerEvents {
['item-images-collected']: Pick<AmazonDetailItem, 'asin' | 'imageUrls'>;
/**
* The event is fired when top reviews collected
* The event is fired when top reviews collected in detail page
*/
['item-top-reviews-collected']: Pick<AmazonDetailItem, 'asin' | 'topReviews'>;
/**
* Error event that occurs when there is an issue with the Amazon page worker.
* The event is fired when reviews collected in all review page
*/
['item-review-collected']: { asin: string; reviews: AmazonReview[] };
/**
* Error event that occurs when there is an issue with the Amazon page worker
*/
['error']: { message: string; url?: string };
}
@ -89,6 +104,16 @@ interface AmazonPageWorker {
*/
runDetaiPageTask(asins: string[], progress?: (remains: string[]) => Promise<void>): Promise<void>;
/**
* Browsing goods review page and collect target information.
* @param asins Amazon Standard Identification Numbers.
* @param progress The callback that receive remaining asins as the parameter.
*/
runReviewPageTask(
asins: string[],
progress?: (remains: string[]) => Promise<void>,
): Promise<void>;
/**
* Stop the worker.
*/

View File

@ -7,27 +7,32 @@ export const asinInputText = useWebExtensionStorage<string>('asinInputText', '')
export const searchItems = useWebExtensionStorage<AmazonSearchItem[]>('searchItems', []);
export const detailItems = useWebExtensionStorage<AmazonDetailItem[]>('detailItems', []);
export const detailItems = useWebExtensionStorage<Map<string, AmazonDetailItem>>(
'detailItems',
new Map(),
{
listenToStorageChanges: false,
},
);
export const allItems = computed({
get() {
const sItems = searchItems.value;
const dItems = detailItems.value.reduce<Map<string, AmazonDetailItem>>(
(m, c) => (m.set(c.asin, c), m),
new Map(),
);
const dItems = detailItems.value;
return sItems.map<AmazonItem>((si) => {
const asin = si.asin;
const dItem = dItems.get(asin);
return dItem ? { ...si, ...dItem, hasDetail: true } : { ...si, hasDetail: false };
return dItems.has(asin) ? { ...si, ...dItem, hasDetail: true } : { ...si, hasDetail: false };
});
},
set(newValue) {
const searchItemProps: (keyof AmazonSearchItem)[] = [
'keywords',
'asin',
'page',
'title',
'imageSrc',
'price',
'link',
'rank',
'createTime',
@ -45,14 +50,17 @@ export const allItems = computed({
'imageUrls',
'rating',
'ratingCount',
'topReviews',
];
detailItems.value = newValue
.filter((row) => row.hasDetail)
.map((row) => {
const entries: [string, unknown][] = Object.entries(row).filter(([key]) =>
.reduce<Map<string, AmazonDetailItem>>((m, row) => {
const entries = Object.entries(row).filter(([key]) =>
detailItemsProps.includes(key as keyof AmazonDetailItem),
);
return Object.fromEntries(entries) as AmazonSearchItem;
});
const obj = Object.fromEntries(entries) as AmazonDetailItem;
m.set(obj.asin, obj);
return m;
}, new Map());
},
});

View File

@ -1,16 +1,22 @@
import { exec } from './execute-script';
import type { Tabs } from 'webextension-polyfill';
import type { AmazonReview } from './page-worker/types';
import type { AmazonReview, AmazonSearchItem } from './page-worker/types';
export class AmazonSearchPageInjector {
class BaseInjector {
readonly _tab: Tabs.Tab;
constructor(tab: Tabs.Tab) {
this._tab = tab;
}
public async waitForPageLoaded() {
return exec(this._tab.id!, async () => {
run<T>(func: (payload?: any) => Promise<T>, payload?: any): Promise<T> {
return exec(this._tab.id!, func, payload);
}
}
export class AmazonSearchPageInjector extends BaseInjector {
public waitForPageLoaded() {
return this.run(async () => {
await new Promise((resolve) => setTimeout(resolve, 500 + ~~(500 * Math.random())));
while (true) {
const targetNode = document.querySelector('.s-pagination-next');
@ -34,61 +40,81 @@ export class AmazonSearchPageInjector {
}
public async getPagePattern() {
return exec(this._tab.id!, async () => {
return [
...(document.querySelectorAll<HTMLDivElement>(
return this.run(async () => {
return 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).length > 0
),
).filter((e) => e.getClientRects().length > 0).length > 0
? 'pattern-1'
: 'pattern-2';
});
}
public async getPageData(pattern: 'pattern-1' | 'pattern-2') {
let data: { link: string; title: string; imageSrc: string }[] | null = null;
let data: Pick<AmazonSearchItem, 'link' | 'title' | 'imageSrc' | 'price'>[] | null = null;
switch (pattern) {
// 处理商品以列表形式展示的情况
case 'pattern-1':
data = await exec(this._tab.id!, async () => {
data = await this.run(async () => {
const items = Array.from(
document.querySelectorAll<HTMLDivElement>(
'.puisg-row:has(.a-section.a-spacing-small.a-spacing-top-small:not(.a-text-right))',
),
).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;
const title = el
.querySelector<HTMLHeadingElement>('h2.a-color-base')!
.getAttribute('aria-label')!;
const imageSrc = el.querySelector<HTMLImageElement>('img.s-image')!.src!;
link && objs.push({ link, title, imageSrc });
return objs;
},
[],
);
const linkObjs = items.reduce<
Pick<AmazonSearchItem, 'link' | 'title' | 'imageSrc' | 'price'>[]
>((objs, el) => {
const link = el.querySelector<HTMLAnchorElement>('a')?.href;
const title = el
.querySelector<HTMLHeadingElement>('h2.a-color-base')!
.getAttribute('aria-label')!;
const imageSrc = el.querySelector<HTMLImageElement>('img.s-image')!.src!;
const price =
el.querySelector<HTMLElement>('.a-price:not(.a-text-price) .a-offscreen')
?.innerText ||
(
document.evaluate(
`.//div[@data-cy="secondary-offer-recipe"]//span[@class='a-color-base' and contains(., '$') and not(*)]`,
el,
null,
XPathResult.FIRST_ORDERED_NODE_TYPE,
).singleNodeValue as HTMLSpanElement | null
)?.innerText;
link && objs.push({ link, title, imageSrc, price });
return objs;
}, []);
return linkObjs;
});
break;
// 处理商品以二维图片格展示的情况
case 'pattern-2':
data = await exec(this._tab.id!, async () => {
data = await this.run(async () => {
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);
const linkObjs = items.reduce<{ link: string; title: string; imageSrc: string }[]>(
(objs, el) => {
const link = el.querySelector<HTMLAnchorElement>('a.a-link-normal')?.href;
const title = el.querySelector<HTMLHeadingElement>('h2.a-color-base')!.innerText;
const imageSrc = el.querySelector<HTMLImageElement>('img.s-image')!.src!;
link && objs.push({ link, title, imageSrc });
return objs;
},
[],
);
const linkObjs = items.reduce<
Pick<AmazonSearchItem, 'link' | 'title' | 'imageSrc' | 'price'>[]
>((objs, el) => {
const link = el.querySelector<HTMLAnchorElement>('a.a-link-normal')?.href;
const title = el.querySelector<HTMLHeadingElement>('h2.a-color-base')!.innerText;
const imageSrc = el.querySelector<HTMLImageElement>('img.s-image')!.src!;
const price =
el.querySelector<HTMLElement>('.a-price:not(.a-text-price) .a-offscreen')
?.innerText ||
(
document.evaluate(
`.//div[@data-cy="secondary-offer-recipe"]//span[@class='a-color-base' and contains(., '$') and not(*)]`,
el,
null,
XPathResult.FIRST_ORDERED_NODE_TYPE,
).singleNodeValue as HTMLSpanElement | null
)?.innerText;
link && objs.push({ link, title, imageSrc, price });
return objs;
}, []);
return linkObjs;
});
break;
@ -100,7 +126,7 @@ export class AmazonSearchPageInjector {
}
public async getCurrentPage() {
return exec<number>(this._tab.id!, async () => {
return this.run(async () => {
const node = document.querySelector<HTMLDivElement>(
'.s-pagination-item.s-pagination-selected',
);
@ -109,7 +135,7 @@ export class AmazonSearchPageInjector {
}
public async determineHasNextPage() {
return exec(this._tab.id!, async () => {
return this.run(async () => {
const nextButton = document.querySelector<HTMLLinkElement>('.s-pagination-next');
if (nextButton) {
if (!nextButton.classList.contains('s-pagination-disabled')) {
@ -126,15 +152,9 @@ export class AmazonSearchPageInjector {
}
}
export class AmazonDetailPageInjector {
readonly _tab: Tabs.Tab;
constructor(tab: Tabs.Tab) {
this._tab = tab;
}
export class AmazonDetailPageInjector extends BaseInjector {
public async waitForPageLoaded() {
return exec(this._tab.id!, async () => {
return this.run(async () => {
while (true) {
window.scrollBy(0, ~~(Math.random() * 500) + 500);
await new Promise((resolve) => setTimeout(resolve, ~~(Math.random() * 100) + 200));
@ -156,8 +176,18 @@ export class AmazonDetailPageInjector {
});
}
public async getBaseInfo() {
return this.run(async () => {
const title = document.querySelector<HTMLElement>('#title')!.innerText;
const price = document.querySelector<HTMLElement>(
'.a-price:not(.a-text-price) .a-offscreen',
)?.innerText;
return { title, price };
});
}
public async getRatingInfo() {
return await exec(this._tab.id!, async () => {
return this.run(async () => {
const review = document.querySelector('#averageCustomerReviews');
const rating = Number(
review?.querySelector('#acrPopover')?.getAttribute('title')?.split(' ')[0],
@ -177,9 +207,9 @@ export class AmazonDetailPageInjector {
}
public async getRankText() {
return exec(this._tab.id!, async () => {
return this.run(async () => {
const xpathExps = [
`//div[@id='detailBulletsWrapper_feature_div']//ul[.//li[contains(., 'Best Sellers Rank')]]//span[@class='a-list-item']`,
`//div[@id='detailBulletsWrapper_feature_div']//ul[.//li[contains(., 'Best Sellers Rank')]]//span[@class='a-list-item' and contains(., 'Best Sellers Rank')]`,
`//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`,
];
@ -201,7 +231,7 @@ export class AmazonDetailPageInjector {
}
public async getImageUrls() {
return exec(this._tab.id!, async () => {
return this.run(async () => {
let urls = Array.from(document.querySelectorAll<HTMLImageElement>('.imageThumbnail img')).map(
(e) => e.src,
);
@ -212,11 +242,9 @@ export class AmazonDetailPageInjector {
overlay.click();
await new Promise((resolve) => setTimeout(resolve, 1000));
}
urls = [
...(document.querySelectorAll(
'#ivThumbs .ivThumbImage[style]',
) as unknown as HTMLDivElement[]),
].map((e) => e.style.background);
urls = Array.from(
document.querySelectorAll<HTMLDivElement>('#ivThumbs .ivThumbImage[style]'),
).map((e) => e.style.background);
urls = urls.map((s) => {
const [url] = /(?<=url\(").+(?=")/.exec(s)!;
return url;
@ -243,7 +271,7 @@ export class AmazonDetailPageInjector {
}
public async getTopReviews() {
return exec<Omit<AmazonReview, 'asin'>[]>(this._tab.id!, async () => {
return this.run(async () => {
const targetNode = document.querySelector<HTMLDivElement>('.cr-widget-FocalReviews');
if (!targetNode) {
return [];
@ -258,18 +286,19 @@ export class AmazonDetailPageInjector {
null,
XPathResult.ORDERED_NODE_SNAPSHOT_TYPE,
);
const items: Omit<AmazonReview, 'asin'>[] = [];
const items: AmazonReview[] = [];
for (let i = 0; i < xResult.snapshotLength; i++) {
const commentNode = xResult.snapshotItem(i) as HTMLDivElement | null;
if (!commentNode) {
continue;
}
const id = commentNode.id.split('-')[0];
const username = commentNode.querySelector<HTMLDivElement>('.a-profile-name')!.innerText;
const title = commentNode.querySelector<HTMLDivElement>(
'[data-hook="review-title"] > span:not(.a-letter-space)',
)!.innerText;
const rating = commentNode.querySelector<HTMLDivElement>(
'[data-hook="review-star-rating"]',
'[data-hook*="review-star-rating"]',
)!.innerText;
const dateInfo = commentNode.querySelector<HTMLDivElement>(
'[data-hook="review-date"]',
@ -277,9 +306,101 @@ export class AmazonDetailPageInjector {
const content = commentNode.querySelector<HTMLDivElement>(
'[data-hook="review-body"]',
)!.innerText;
items.push({ username, title, rating, dateInfo, content });
items.push({ id, username, title, rating, dateInfo, content });
}
return items;
});
}
}
export class AmazonReviewPageInjector extends BaseInjector {
public async waitForPageLoad() {
return this.run(async () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
while (true) {
const targetNode = document.querySelector(
'#cm_cr-review_list .reviews-content,ul[role="list"]:not(.histogram)',
);
targetNode?.scrollIntoView({ behavior: 'smooth', block: 'center' });
if (
targetNode &&
targetNode.getClientRects().length > 0 &&
document.readyState !== 'loading'
) {
break;
}
await new Promise((resolve) => setTimeout(resolve, 1000));
}
while (true) {
const loadingNode = document.querySelector('.reviews-loading');
if (loadingNode && loadingNode.getClientRects().length === 0) {
break;
}
await new Promise((resolve) => setTimeout(resolve, 1000));
}
});
}
public async getSinglePageReviews() {
return this.run(async () => {
const targetNode = document.querySelector('#cm_cr-review_list');
if (!targetNode) {
return [];
}
// targetNode.scrollIntoView({ behavior: "smooth", block: "end" })
await new Promise((resolve) => setTimeout(resolve, 1000));
const xResult = document.evaluate(
`.//div[contains(@id, 'review-card')]`,
targetNode,
null,
XPathResult.ORDERED_NODE_SNAPSHOT_TYPE,
);
const items: AmazonReview[] = [];
for (let i = 0; i < xResult.snapshotLength; i++) {
console.log('handling', i);
const commentNode = xResult.snapshotItem(i) as HTMLDivElement;
if (!commentNode) {
continue;
}
const id = commentNode.id.split('-')[0];
const username = commentNode.querySelector<HTMLDivElement>('.a-profile-name')!.innerText;
const title = commentNode.querySelector<HTMLDivElement>(
'[data-hook="review-title"] > span:not(.a-letter-space)',
)!.innerText;
const rating = commentNode.querySelector<HTMLDivElement>(
'[data-hook*="review-star-rating"]',
)!.innerText;
const dateInfo = commentNode.querySelector<HTMLDivElement>(
'[data-hook="review-date"]',
)!.innerText;
const content = commentNode.querySelector<HTMLDivElement>(
'[data-hook="review-body"]',
)!.innerText;
items.push({ id, username, title, rating, dateInfo, content });
}
return items;
});
}
public jumpToNextPageIfExist() {
return this.run(async () => {
const latestReview = document.evaluate(
`//*[@id='cm_cr-review_list']//li[@data-hook='review'][last()]`,
document.body,
null,
XPathResult.FIRST_ORDERED_NODE_TYPE,
).singleNodeValue as HTMLElement | null;
latestReview?.scrollIntoView({ behavior: 'smooth', block: 'center' });
await new Promise((resolve) => setTimeout(resolve, 1000));
const nextPageNode = document.querySelector<HTMLDivElement>(
'[data-hook="pagination-bar"] .a-pagination > *:nth-of-type(2)',
);
nextPageNode?.scrollIntoView({ behavior: 'smooth', block: 'center' });
await new Promise((resolve) => setTimeout(resolve, 1000));
const ret = nextPageNode && !nextPageNode.classList.contains('a-disabled');
ret && nextPageNode?.querySelector('a')?.click();
return ret;
});
}
}

View File

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

View File

@ -1,6 +1,6 @@
<script setup lang="ts">
import type { FormItemRule, UploadOnChange } from 'naive-ui';
import pageWorkerFactory from '~/logic/page-worker';
import pageWorker from '~/logic/page-worker';
import { AmazonDetailItem } from '~/logic/page-worker/types';
import { asinInputText, detailItems } from '~/logic/storage';
@ -27,7 +27,8 @@ const timelines = ref<
const running = ref(false);
const worker = pageWorkerFactory.useAmazonPageWorker(); // Page Worker
//#region Page Worker Code
const worker = pageWorker.useAmazonPageWorker(); // Page Worker
worker.channel.on('error', ({ message: msg }) => {
timelines.value.push({
type: 'error',
@ -38,6 +39,15 @@ worker.channel.on('error', ({ message: msg }) => {
message.error(msg);
running.value = false;
});
worker.channel.on('item-base-info-collected', (ev) => {
timelines.value.push({
type: 'success',
title: `商品${ev.asin}基本信息`,
time: new Date().toLocaleString(),
content: `标题: ${ev.title};价格:${ev.price}`,
});
updateDetailItems(ev);
});
worker.channel.on('item-rating-collected', (ev) => {
timelines.value.push({
type: 'success',
@ -77,6 +87,16 @@ worker.channel.on('item-top-reviews-collected', (ev) => {
});
updateDetailItems(ev);
});
const updateDetailItems = (row: { asin: string } & Partial<AmazonDetailItem>) => {
const asin = row.asin;
if (detailItems.value.has(row.asin)) {
const origin = detailItems.value.get(row.asin);
detailItems.value.set(asin, { ...origin, ...row } as AmazonDetailItem);
} else {
detailItems.value.set(asin, row as AmazonDetailItem);
}
};
//#endregion
const handleImportAsin: UploadOnChange = ({ fileList }) => {
if (fileList.length > 0) {
@ -147,17 +167,6 @@ const handleInterrupt = () => {
worker.stop();
message.info('已触发中断,正在等待当前任务完成。', { duration: 2000 });
};
const updateDetailItems = (info: AmazonDetailItem) => {
const targetIndex = detailItems.value.findLastIndex((item) => info.asin === item.asin);
if (targetIndex > -1) {
const origin = detailItems.value[targetIndex];
const updatedItem = { ...origin, ...info };
detailItems.value.splice(targetIndex, 1, updatedItem);
} else {
detailItems.value.push(info);
}
};
</script>
<template>

View File

@ -1,8 +1,25 @@
<script lang="ts" setup></script>
<script lang="ts" setup>
import pageWorker from '~/logic/page-worker';
const worker = pageWorker.useAmazonPageWorker();
worker.channel.on('item-review-collected', (ev) => {
output.value = ev;
});
const inputText = ref('');
const output = ref<any>(null);
const handleStart = () => {
worker.runReviewPageTask(inputText.value.split('\n').filter((t) => /^[A-Z0-9]{10}/.exec(t)));
};
</script>
<template>
<div class="review-page-entry">
<header-title>Review Page</header-title>
<n-input type="textarea" v-model:value="inputText" />
<n-button @click="handleStart">测试</n-button>
<div>{{ output }}</div>
</div>
</template>