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 }} {{ link }}
</div> </div>
</n-descriptions-item> </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"> <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')"> <div v-for="paragraph in review.content.split('\n')">
{{ paragraph }} {{ paragraph }}
</div> </div>
<div style="color: gray; font-size: smaller">{{ review.dateInfo }}</div>
</div> </div>
</n-descriptions-item> </n-descriptions-item>
</n-descriptions> </n-descriptions>

View File

@ -24,7 +24,7 @@ const filterFormItems = computed(() => {
params: { params: {
options: [ options: [
...records.reduce((o, c) => { ...records.reduce((o, c) => {
o.add(c.keywords); c.keywords && o.add(c.keywords);
return o; return o;
}, new Set<string>()), }, new Set<string>()),
].map((opt) => ({ ].map((opt) => ({
@ -67,6 +67,11 @@ const columns: (TableColumn<AmazonItem> & { hidden?: boolean })[] = [
key: 'rank', key: 'rank',
minWidth: 60, minWidth: 60,
}, },
{
title: 'ASIN',
key: 'asin',
minWidth: 130,
},
{ {
title: '标题', title: '标题',
key: 'title', key: 'title',
@ -75,9 +80,9 @@ const columns: (TableColumn<AmazonItem> & { hidden?: boolean })[] = [
}, },
}, },
{ {
title: 'ASIN', title: '价格',
key: 'asin', key: 'price',
minWidth: 130, minWidth: 100,
}, },
{ {
title: '封面图', title: '封面图',
@ -141,6 +146,12 @@ const extraHeaders: Header[] = [
formatOutputValue: (val?: string[]) => val?.join(';'), formatOutputValue: (val?: string[]) => val?.join(';'),
parseImportValue: (val?: string) => val?.split(';'), 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[] => { const filterItemData = (data: AmazonItem[]): AmazonItem[] => {
@ -148,7 +159,7 @@ const filterItemData = (data: AmazonItem[]): AmazonItem[] => {
if (search.trim() !== '') { if (search.trim() !== '') {
data = data.filter((r) => { data = data.filter((r) => {
return [r.title, r.asin, r.keywords].some((field) => 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 { AmazonDetailItem, AmazonPageWorker, AmazonPageWorkerEvents } from './types';
import type { Tabs } from 'webextension-polyfill'; import type { Tabs } from 'webextension-polyfill';
import { withErrorHandling } from '../error-handler'; 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, * AmazonPageWorkerImpl can run on background & sidepanel & popup,
@ -75,7 +79,7 @@ class AmazonPageWorkerImpl implements AmazonPageWorker {
} }
const currentUrl = new URL(tab.url!); const currentUrl = new URL(tab.url!);
if (currentUrl.hostname !== url.hostname || currentUrl.searchParams.get('k') !== keywords) { 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)); await new Promise<void>((resolve) => setTimeout(resolve, 1000));
} }
return url.toString(); return url.toString();
@ -127,10 +131,18 @@ class AmazonPageWorkerImpl implements AmazonPageWorker {
} }
const injector = new AmazonDetailPageInjector(tab); const injector = new AmazonDetailPageInjector(tab);
//#endregion //#endregion
//#region Await Production Introduction Element Loaded and Determine Page Pattern //#region Await Production Introduction Element Loaded
await injector.waitForPageLoaded(); await injector.waitForPageLoaded();
await new Promise((resolve) => setTimeout(resolve, 3000)); // Wait 3 seconds. await new Promise((resolve) => setTimeout(resolve, 3000)); // Wait 3 seconds.
//#endregion //#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 //#region Fetch Rating Info
const ratingInfo = await injector.getRatingInfo(); const ratingInfo = await injector.getRatingInfo();
if (ratingInfo && (ratingInfo.rating !== 0 || ratingInfo.ratingCount !== 0)) { if (ratingInfo && (ratingInfo.rating !== 0 || ratingInfo.ratingCount !== 0)) {
@ -181,11 +193,28 @@ class AmazonPageWorkerImpl implements AmazonPageWorker {
reviews.length > 0 && reviews.length > 0 &&
this.channel.emit('item-top-reviews-collected', { this.channel.emit('item-top-reviews-collected', {
asin: params.asin, asin: params.asin,
topReviews: reviews.map((r) => ({ asin: params.asin, ...r })), topReviews: reviews,
}); });
//#endregion //#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( public async runSearchPageTask(
keywordsList: string[], keywordsList: string[],
progress?: (remains: string[]) => Promise<void>, progress?: (remains: string[]) => Promise<void>,
@ -221,17 +250,29 @@ class AmazonPageWorkerImpl implements AmazonPageWorker {
unsubscribe(); 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> { public async stop(): Promise<void> {
this._controlChannel.emit('interrupt'); this._controlChannel.emit('interrupt');
} }
} }
class PageWorkerFactory { class PageWorker {
public useAmazonPageWorker(): AmazonPageWorker { public useAmazonPageWorker(): AmazonPageWorker {
return AmazonPageWorkerImpl.getInstance(); 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; title: string;
asin: string; asin: string;
rank: number; rank: number;
price?: string;
imageSrc: string; imageSrc: string;
createTime: string; createTime: string;
}; };
type AmazonDetailItem = { type AmazonDetailItem = {
asin: string; asin: string;
title: string;
price?: string;
rating?: number; rating?: number;
ratingCount?: number; ratingCount?: number;
category1?: { name: string; rank: number }; category1?: { name: string; rank: number };
@ -23,7 +26,7 @@ type AmazonDetailItem = {
}; };
type AmazonReview = { type AmazonReview = {
asin: string; id: string;
username: string; username: string;
title: string; title: string;
rating: string; rating: string;
@ -31,7 +34,9 @@ type AmazonReview = {
content: string; content: string;
}; };
type AmazonItem = AmazonSearchItem & Partial<AmazonDetailItem> & { hasDetail: boolean }; type AmazonItem = Pick<AmazonSearchItem, 'asin'> &
Partial<AmazonSearchItem> &
Partial<AmazonDetailItem> & { hasDetail: boolean };
interface AmazonPageWorkerEvents { interface AmazonPageWorkerEvents {
/** /**
@ -39,6 +44,11 @@ interface AmazonPageWorkerEvents {
*/ */
['item-links-collected']: { objs: AmazonSearchItem[] }; ['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. * 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'>; ['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'>; ['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 }; ['error']: { message: string; url?: string };
} }
@ -89,6 +104,16 @@ interface AmazonPageWorker {
*/ */
runDetaiPageTask(asins: string[], progress?: (remains: string[]) => Promise<void>): Promise<void>; 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. * Stop the worker.
*/ */

View File

@ -7,27 +7,32 @@ export const asinInputText = useWebExtensionStorage<string>('asinInputText', '')
export const searchItems = useWebExtensionStorage<AmazonSearchItem[]>('searchItems', []); 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({ export const allItems = computed({
get() { get() {
const sItems = searchItems.value; const sItems = searchItems.value;
const dItems = detailItems.value.reduce<Map<string, AmazonDetailItem>>( const dItems = detailItems.value;
(m, c) => (m.set(c.asin, c), m),
new Map(),
);
return sItems.map<AmazonItem>((si) => { return sItems.map<AmazonItem>((si) => {
const asin = si.asin; const asin = si.asin;
const dItem = dItems.get(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) { set(newValue) {
const searchItemProps: (keyof AmazonSearchItem)[] = [ const searchItemProps: (keyof AmazonSearchItem)[] = [
'keywords', 'keywords',
'asin', 'asin',
'page',
'title', 'title',
'imageSrc', 'imageSrc',
'price',
'link', 'link',
'rank', 'rank',
'createTime', 'createTime',
@ -45,14 +50,17 @@ export const allItems = computed({
'imageUrls', 'imageUrls',
'rating', 'rating',
'ratingCount', 'ratingCount',
'topReviews',
]; ];
detailItems.value = newValue detailItems.value = newValue
.filter((row) => row.hasDetail) .filter((row) => row.hasDetail)
.map((row) => { .reduce<Map<string, AmazonDetailItem>>((m, row) => {
const entries: [string, unknown][] = Object.entries(row).filter(([key]) => const entries = Object.entries(row).filter(([key]) =>
detailItemsProps.includes(key as keyof AmazonDetailItem), 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 { exec } from './execute-script';
import type { Tabs } from 'webextension-polyfill'; 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; readonly _tab: Tabs.Tab;
constructor(tab: Tabs.Tab) { constructor(tab: Tabs.Tab) {
this._tab = tab; this._tab = tab;
} }
public async waitForPageLoaded() { run<T>(func: (payload?: any) => Promise<T>, payload?: any): Promise<T> {
return exec(this._tab.id!, async () => { 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()))); await new Promise((resolve) => setTimeout(resolve, 500 + ~~(500 * Math.random())));
while (true) { while (true) {
const targetNode = document.querySelector('.s-pagination-next'); const targetNode = document.querySelector('.s-pagination-next');
@ -34,61 +40,81 @@ export class AmazonSearchPageInjector {
} }
public async getPagePattern() { public async getPagePattern() {
return exec(this._tab.id!, async () => { return this.run(async () => {
return [ return 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).length > 0 ).filter((e) => e.getClientRects().length > 0).length > 0
? 'pattern-1' ? 'pattern-1'
: 'pattern-2'; : 'pattern-2';
}); });
} }
public async getPageData(pattern: '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) { switch (pattern) {
// 处理商品以列表形式展示的情况 // 处理商品以列表形式展示的情况
case 'pattern-1': case 'pattern-1':
data = await exec(this._tab.id!, async () => { data = await this.run(async () => {
const items = Array.from( 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))',
), ),
).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<
(objs, el) => { Pick<AmazonSearchItem, 'link' | 'title' | 'imageSrc' | 'price'>[]
const link = el.querySelector<HTMLAnchorElement>('a')?.href; >((objs, el) => {
const title = el const link = el.querySelector<HTMLAnchorElement>('a')?.href;
.querySelector<HTMLHeadingElement>('h2.a-color-base')! const title = el
.getAttribute('aria-label')!; .querySelector<HTMLHeadingElement>('h2.a-color-base')!
const imageSrc = el.querySelector<HTMLImageElement>('img.s-image')!.src!; .getAttribute('aria-label')!;
link && objs.push({ link, title, imageSrc }); const imageSrc = el.querySelector<HTMLImageElement>('img.s-image')!.src!;
return objs; 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; return linkObjs;
}); });
break; break;
// 处理商品以二维图片格展示的情况 // 处理商品以二维图片格展示的情况
case 'pattern-2': case 'pattern-2':
data = await exec(this._tab.id!, async () => { data = await this.run(async () => {
const items = Array.from( 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<
(objs, el) => { Pick<AmazonSearchItem, 'link' | 'title' | 'imageSrc' | 'price'>[]
const link = el.querySelector<HTMLAnchorElement>('a.a-link-normal')?.href; >((objs, el) => {
const title = el.querySelector<HTMLHeadingElement>('h2.a-color-base')!.innerText; const link = el.querySelector<HTMLAnchorElement>('a.a-link-normal')?.href;
const imageSrc = el.querySelector<HTMLImageElement>('img.s-image')!.src!; const title = el.querySelector<HTMLHeadingElement>('h2.a-color-base')!.innerText;
link && objs.push({ link, title, imageSrc }); const imageSrc = el.querySelector<HTMLImageElement>('img.s-image')!.src!;
return objs; 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; return linkObjs;
}); });
break; break;
@ -100,7 +126,7 @@ export class AmazonSearchPageInjector {
} }
public async getCurrentPage() { public async getCurrentPage() {
return exec<number>(this._tab.id!, async () => { return this.run(async () => {
const node = document.querySelector<HTMLDivElement>( const node = document.querySelector<HTMLDivElement>(
'.s-pagination-item.s-pagination-selected', '.s-pagination-item.s-pagination-selected',
); );
@ -109,7 +135,7 @@ export class AmazonSearchPageInjector {
} }
public async determineHasNextPage() { public async determineHasNextPage() {
return exec(this._tab.id!, async () => { return this.run(async () => {
const nextButton = document.querySelector<HTMLLinkElement>('.s-pagination-next'); const nextButton = document.querySelector<HTMLLinkElement>('.s-pagination-next');
if (nextButton) { if (nextButton) {
if (!nextButton.classList.contains('s-pagination-disabled')) { if (!nextButton.classList.contains('s-pagination-disabled')) {
@ -126,15 +152,9 @@ export class AmazonSearchPageInjector {
} }
} }
export class AmazonDetailPageInjector { export class AmazonDetailPageInjector extends BaseInjector {
readonly _tab: Tabs.Tab;
constructor(tab: Tabs.Tab) {
this._tab = tab;
}
public async waitForPageLoaded() { public async waitForPageLoaded() {
return exec(this._tab.id!, async () => { return this.run(async () => {
while (true) { while (true) {
window.scrollBy(0, ~~(Math.random() * 500) + 500); window.scrollBy(0, ~~(Math.random() * 500) + 500);
await new Promise((resolve) => setTimeout(resolve, ~~(Math.random() * 100) + 200)); 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() { public async getRatingInfo() {
return await exec(this._tab.id!, async () => { return this.run(async () => {
const review = document.querySelector('#averageCustomerReviews'); const review = document.querySelector('#averageCustomerReviews');
const rating = Number( const rating = Number(
review?.querySelector('#acrPopover')?.getAttribute('title')?.split(' ')[0], review?.querySelector('#acrPopover')?.getAttribute('title')?.split(' ')[0],
@ -177,9 +207,9 @@ export class AmazonDetailPageInjector {
} }
public async getRankText() { public async getRankText() {
return exec(this._tab.id!, async () => { return this.run(async () => {
const xpathExps = [ 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='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`, `//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() { public async getImageUrls() {
return exec(this._tab.id!, async () => { return this.run(async () => {
let urls = Array.from(document.querySelectorAll<HTMLImageElement>('.imageThumbnail img')).map( let urls = Array.from(document.querySelectorAll<HTMLImageElement>('.imageThumbnail img')).map(
(e) => e.src, (e) => e.src,
); );
@ -212,11 +242,9 @@ export class AmazonDetailPageInjector {
overlay.click(); overlay.click();
await new Promise((resolve) => setTimeout(resolve, 1000)); await new Promise((resolve) => setTimeout(resolve, 1000));
} }
urls = [ urls = Array.from(
...(document.querySelectorAll( document.querySelectorAll<HTMLDivElement>('#ivThumbs .ivThumbImage[style]'),
'#ivThumbs .ivThumbImage[style]', ).map((e) => e.style.background);
) as unknown as HTMLDivElement[]),
].map((e) => e.style.background);
urls = urls.map((s) => { urls = urls.map((s) => {
const [url] = /(?<=url\(").+(?=")/.exec(s)!; const [url] = /(?<=url\(").+(?=")/.exec(s)!;
return url; return url;
@ -243,7 +271,7 @@ export class AmazonDetailPageInjector {
} }
public async getTopReviews() { public async getTopReviews() {
return exec<Omit<AmazonReview, 'asin'>[]>(this._tab.id!, async () => { return this.run(async () => {
const targetNode = document.querySelector<HTMLDivElement>('.cr-widget-FocalReviews'); const targetNode = document.querySelector<HTMLDivElement>('.cr-widget-FocalReviews');
if (!targetNode) { if (!targetNode) {
return []; return [];
@ -258,18 +286,19 @@ export class AmazonDetailPageInjector {
null, null,
XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE,
); );
const items: Omit<AmazonReview, 'asin'>[] = []; const items: AmazonReview[] = [];
for (let i = 0; i < xResult.snapshotLength; i++) { for (let i = 0; i < xResult.snapshotLength; i++) {
const commentNode = xResult.snapshotItem(i) as HTMLDivElement | null; const commentNode = xResult.snapshotItem(i) as HTMLDivElement | null;
if (!commentNode) { if (!commentNode) {
continue; continue;
} }
const id = commentNode.id.split('-')[0];
const username = commentNode.querySelector<HTMLDivElement>('.a-profile-name')!.innerText; const username = commentNode.querySelector<HTMLDivElement>('.a-profile-name')!.innerText;
const title = commentNode.querySelector<HTMLDivElement>( const title = commentNode.querySelector<HTMLDivElement>(
'[data-hook="review-title"] > span:not(.a-letter-space)', '[data-hook="review-title"] > span:not(.a-letter-space)',
)!.innerText; )!.innerText;
const rating = commentNode.querySelector<HTMLDivElement>( const rating = commentNode.querySelector<HTMLDivElement>(
'[data-hook="review-star-rating"]', '[data-hook*="review-star-rating"]',
)!.innerText; )!.innerText;
const dateInfo = commentNode.querySelector<HTMLDivElement>( const dateInfo = commentNode.querySelector<HTMLDivElement>(
'[data-hook="review-date"]', '[data-hook="review-date"]',
@ -277,9 +306,101 @@ export class AmazonDetailPageInjector {
const content = commentNode.querySelector<HTMLDivElement>( const content = commentNode.querySelector<HTMLDivElement>(
'[data-hook="review-body"]', '[data-hook="review-body"]',
)!.innerText; )!.innerText;
items.push({ username, title, rating, dateInfo, content }); items.push({ id, username, title, rating, dateInfo, content });
} }
return items; 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; align-items: center;
.result-table { .result-table {
width: 90%; width: 95%;
} }
} }
</style> </style>

View File

@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import type { FormItemRule, UploadOnChange } from 'naive-ui'; 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 { AmazonDetailItem } from '~/logic/page-worker/types';
import { asinInputText, detailItems } from '~/logic/storage'; import { asinInputText, detailItems } from '~/logic/storage';
@ -27,7 +27,8 @@ const timelines = ref<
const running = ref(false); 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 }) => { worker.channel.on('error', ({ message: msg }) => {
timelines.value.push({ timelines.value.push({
type: 'error', type: 'error',
@ -38,6 +39,15 @@ worker.channel.on('error', ({ message: msg }) => {
message.error(msg); message.error(msg);
running.value = false; 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) => { worker.channel.on('item-rating-collected', (ev) => {
timelines.value.push({ timelines.value.push({
type: 'success', type: 'success',
@ -77,6 +87,16 @@ worker.channel.on('item-top-reviews-collected', (ev) => {
}); });
updateDetailItems(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 }) => { const handleImportAsin: UploadOnChange = ({ fileList }) => {
if (fileList.length > 0) { if (fileList.length > 0) {
@ -147,17 +167,6 @@ const handleInterrupt = () => {
worker.stop(); worker.stop();
message.info('已触发中断,正在等待当前任务完成。', { duration: 2000 }); 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> </script>
<template> <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> <template>
<div class="review-page-entry"> <div class="review-page-entry">
<header-title>Review Page</header-title> <header-title>Review Page</header-title>
<n-input type="textarea" v-model:value="inputText" />
<n-button @click="handleStart">测试</n-button>
<div>{{ output }}</div>
</div> </div>
</template> </template>