mirror of
https://github.com/primedigitaltech/azon_seeker.git
synced 2026-01-31 20:03:26 +08:00
Update UI & Worker
This commit is contained in:
parent
3734c83d21
commit
a84f148743
@ -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>
|
||||
|
||||
@ -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()),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
33
src/logic/page-worker/types.d.ts
vendored
33
src/logic/page-worker/types.d.ts
vendored
@ -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.
|
||||
*/
|
||||
|
||||
@ -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());
|
||||
},
|
||||
});
|
||||
|
||||
@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -14,7 +14,7 @@ main {
|
||||
align-items: center;
|
||||
|
||||
.result-table {
|
||||
width: 90%;
|
||||
width: 95%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user