diff --git a/src/options/Options.vue b/src/options/Options.vue index ebb4556..b2cc050 100644 --- a/src/options/Options.vue +++ b/src/options/Options.vue @@ -13,6 +13,7 @@ const options: { label: string; value: string }[] = [ { label: 'Amazon', value: '/amazon' }, { label: 'Amazon Review', value: '/amazon-reviews' }, { label: 'Homedepot', value: '/homedepot' }, + { label: 'Homedepot Review', value: '/homedepot-reviews' }, ]; watch(opt, (val) => { diff --git a/src/options/views/HomedepotReviews.vue b/src/options/views/HomedepotReviews.vue new file mode 100644 index 0000000..15bc34e --- /dev/null +++ b/src/options/views/HomedepotReviews.vue @@ -0,0 +1,226 @@ + + + + + diff --git a/src/page-worker/composables/amazon.ts b/src/page-worker/composables/amazon.ts index a47933d..e3abe16 100644 --- a/src/page-worker/composables/amazon.ts +++ b/src/page-worker/composables/amazon.ts @@ -90,9 +90,9 @@ function buildAmazonPageWorker(): WorkerComposable x.id)); + const addedIds = new Set(reviews.map((x) => x.id)); const origin = reviewItems.value.get(asin)!; - const newReviews = origin.filter((x) => !addIds.has(x.id)).concat(reviews); + const newReviews = origin.filter((x) => !addedIds.has(x.id)).concat(reviews); newReviews.sort((a, b) => dayjs(b.dateInfo).diff(dayjs(a.dateInfo))); reviewItems.value.set(asin, newReviews); } else { @@ -104,12 +104,9 @@ function buildAmazonPageWorker(): WorkerComposable void)[] = []; - // Register all relevant worker event handlers function registerWorkerEvents() { - return [ + const unsubscribes = [ // Stop worker on error worker.on('error', () => { worker.stop(); @@ -144,17 +141,18 @@ function buildAmazonPageWorker(): WorkerComposable { + unsubscribes.forEach((unsubscribe) => unsubscribe()); + }; } // Register event handlers on mount - onMounted(() => { - unsubscribes.push(...registerWorkerEvents()); - }); + const unsbuscribe = registerWorkerEvents(); // Unregister event handlers on unmount onUnmounted(() => { - unsubscribes.forEach((unsubscribe) => unsubscribe()); - unsubscribes.splice(0, unsubscribes.length); + unsbuscribe(); }); /** diff --git a/src/page-worker/composables/homedepot.ts b/src/page-worker/composables/homedepot.ts index dc5b709..5e58c2e 100644 --- a/src/page-worker/composables/homedepot.ts +++ b/src/page-worker/composables/homedepot.ts @@ -1,5 +1,5 @@ import { useLongTask } from '~/composables/useLongTask'; -import { detailItems as homedepotDetailItems } from '~/storages/homedepot'; +import { detailItems, reviewItems } from '~/storages/homedepot'; import homedepot from '../impls/homedepot'; import { createGlobalState } from '@vueuse/core'; import { WorkerComposable } from '../interfaces/common'; @@ -16,45 +16,63 @@ function buildHomedepotWorker(): WorkerComposable(); + const reviewCache = new Map(); - const unsubscribeFuncs = [] as (() => void)[]; + function registerWorkerEvents() { + const unsubscribes = [ + worker.on('error', () => { + worker.stop(); + }), + worker.on('detail-item-collected', (ev) => { + const { item } = ev; + if (detailCache.has(item.OSMID)) { + const origin = detailCache.get(item.OSMID); + detailCache.set(item.OSMID, { ...origin, ...item }); + } else { + detailCache.set(item.OSMID, item); + } + }), + worker.on('review-collected', (ev) => { + const { OSMID, reviews } = ev; + reviewCache.set(OSMID, (reviewCache.get(OSMID) || []).concat(reviews)); + }), + ]; + return () => { + unsubscribes.forEach((unsubscribe) => unsubscribe()); + }; + } - onMounted(() => { - unsubscribeFuncs.push( - ...[ - worker.on('error', () => { - worker.stop(); - }), - worker.on('detail-item-collected', (ev) => { - const { item } = ev; - if (detailCache.has(item.OSMID)) { - const origin = detailCache.get(item.OSMID); - detailCache.set(item.OSMID, { ...origin, ...item }); - } else { - detailCache.set(item.OSMID, item); - } - }), - ], - ); - }); + const unsubscribe = registerWorkerEvents(); onUnmounted(() => { - unsubscribeFuncs.forEach((unsubscribe) => unsubscribe()); - unsubscribeFuncs.splice(0, unsubscribeFuncs.length); + unsubscribe(); }); const commitChange = () => { const { objects } = settings.value; if (objects?.includes('detail')) { for (const [k, v] of detailCache.entries()) { - if (homedepotDetailItems.value.has(k)) { - const origin = homedepotDetailItems.value.get(k)!; - homedepotDetailItems.value.set(k, { ...origin, ...v }); + if (detailItems.value.has(k)) { + const origin = detailItems.value.get(k)!; + detailItems.value.set(k, { ...origin, ...v }); } else { - homedepotDetailItems.value.set(k, v); + detailItems.value.set(k, v); } } detailCache.clear(); + for (const [k, v] of reviewCache.entries()) { + if (reviewItems.value.has(k)) { + const uid = (x: HomedepotReview) => `${x.username}-${x.title}`; + const addedUids = new Set(v.map(uid)); + const origin = reviewItems.value.get(k)!; + const newReviews = origin.filter((x) => !addedUids.has(uid(x))).concat(v); + newReviews.sort((a, b) => dayjs(b.dateInfo).diff(dayjs(a.dateInfo))); + reviewItems.value.set(k, newReviews); + } else { + reviewItems.value.set(k, v); + } + } + reviewCache.clear(); } }; diff --git a/src/page-worker/impls/homedepot.ts b/src/page-worker/impls/homedepot.ts index 9e0f0d4..2787a5c 100644 --- a/src/page-worker/impls/homedepot.ts +++ b/src/page-worker/impls/homedepot.ts @@ -50,10 +50,10 @@ class HomedepotWorkerImpl } await injector.waitForReviewLoad(); const reviews = await injector.getReviews(); - await this.emit('review-collected', { reviews }); + await this.emit('review-collected', { OSMID, reviews }); while (await injector.tryJumpToNextPage()) { const reviews = await injector.getReviews(); - await this.emit('review-collected', { reviews }); + await this.emit('review-collected', { OSMID, reviews }); } setTimeout(() => { browser.tabs.remove(tab.id!); diff --git a/src/page-worker/impls/lowes.ts b/src/page-worker/impls/lowes.ts new file mode 100644 index 0000000..07e4b68 --- /dev/null +++ b/src/page-worker/impls/lowes.ts @@ -0,0 +1,30 @@ +import { taskOptionBase } from '../interfaces/common'; +import { BaseWorker } from './base'; +import { LowesEvents, LowesWorker } from '../interfaces/lowes'; + +class LowesWorkerImpl extends BaseWorker implements LowesWorker { + private static instance: LowesWorker | null = null; + public static getInstance() { + if (!this.instance) { + this.instance = new LowesWorkerImpl(); + } + return this.instance; + } + protected constructor() { + super(); + } + + runDetailPageTask(urls: string[], options?: taskOptionBase): Promise { + throw new Error('Method not implemented.'); + } + + stop(): Promise { + throw new Error('Method not implemented.'); + } +} + +export default { + getLowesWorker() { + return LowesWorkerImpl.getInstance(); + }, +}; diff --git a/src/page-worker/interfaces/common.ts b/src/page-worker/interfaces/common.ts index ebf4ebd..34de5ac 100644 --- a/src/page-worker/interfaces/common.ts +++ b/src/page-worker/interfaces/common.ts @@ -6,31 +6,6 @@ export interface taskOptionBase { progress?: (remains: string[]) => Promise | void; } -export interface LowesEvents { - /** The event is fired when detail items collect */ - ['detail-item-collected']: { item: LowesDetailItem }; - - /** The event is fired when error occurs. */ - ['error']: { message: string; url?: string }; -} - -export interface LowesWorker { - /** - * The channel for communication with the Lowes page worker. - */ - readonly channel: Emittery; - - /** - * Browsing item detail page and collect target information - */ - runDetailPageTask(urls: string[], options?: taskOptionBase): Promise; - - /** - * Stop the worker. - */ - stop(): Promise; -} - export type WorkerComposable = Base & { settings: Ref; isRunning: Ref; diff --git a/src/page-worker/interfaces/homedepot.ts b/src/page-worker/interfaces/homedepot.ts index fddb89e..62afcb8 100644 --- a/src/page-worker/interfaces/homedepot.ts +++ b/src/page-worker/interfaces/homedepot.ts @@ -5,7 +5,7 @@ export interface HomedepotEvents { ['detail-item-collected']: { item: HomedepotDetailItem }; /** The event is fired when reviews collect */ - ['review-collected']: { reviews: HomedepotReview[] }; + ['review-collected']: { OSMID: string; reviews: HomedepotReview[] }; /** The event is fired when error occurs. */ ['error']: { message: string; url?: string }; diff --git a/src/page-worker/interfaces/lowes.ts b/src/page-worker/interfaces/lowes.ts index 3cf3068..d7a8f67 100644 --- a/src/page-worker/interfaces/lowes.ts +++ b/src/page-worker/interfaces/lowes.ts @@ -1,17 +1,18 @@ import { taskOptionBase, Listener } from './common'; -export interface HomedepotEvents { - /**The event is fired when detail base info collected */ - ['detail-base-info-collect']: { item: LowesDetailItem }; - /**The event is fired when error occur */ - ['error']: { message: string }; +export interface LowesEvents { + /** The event is fired when detail items collect */ + ['detail-item-collected']: { item: LowesDetailItem }; + + /** The event is fired when error occurs. */ + ['error']: { message: string; url?: string }; } -export interface HomedepotWorker extends Listener { +export interface LowesWorker { /** * Browsing item detail page and collect target information */ - runDetailPageTask(urls: string[], options: taskOptionBase): Promise; + runDetailPageTask(urls: string[], options?: taskOptionBase): Promise; /** * Stop the worker. diff --git a/src/page-worker/web-injectors/homedepot.ts b/src/page-worker/web-injectors/homedepot.ts index 07f5837..a972f22 100644 --- a/src/page-worker/web-injectors/homedepot.ts +++ b/src/page-worker/web-injectors/homedepot.ts @@ -114,7 +114,7 @@ export class HomedepotDetailPageInjector extends BaseInjector { 'script#thd-helmet__script--productStructureData', )!.innerText; const obj = JSON.parse(text); - return obj['image'] as string[]; + return (obj['image'] as string[]).map((url) => url.slice(1, -1)); }); } @@ -125,6 +125,7 @@ export class HomedepotDetailPageInjector extends BaseInjector { document .querySelector("#product-section-rr div[role='button']") ?.scrollIntoView({ behavior: 'smooth' }); + document.querySelector('li:not(.sui-border-accent) .navlink-rr')?.click(); if (el && el.getClientRects().length > 0 && el.getClientRects()[0].height > 0) { el?.scrollIntoView({ behavior: 'smooth' }); break; @@ -151,8 +152,9 @@ export class HomedepotDetailPageInjector extends BaseInjector { const badges = Array.from( root.querySelectorAll('.review-status-icons__list, li.review-badge > *'), ) - .map((el) => el.innerText) - .filter((t) => !["(What's this?)"].includes(t)); + .map((el) => el.innerText.trim()) + .filter((t) => !["(What's this?)"].includes(t)) + .filter((t) => t.length !== 0); const imageUrls = Array.from( root.querySelectorAll('.media-carousel__media > button'), ).map((el) => el.style.backgroundImage.split(/[\(\)]/, 3)[1]); @@ -163,6 +165,7 @@ export class HomedepotDetailPageInjector extends BaseInjector { public tryJumpToNextPage() { return this.run(async () => { + await new Promise((resolve) => setTimeout(resolve, 1000)); const final = document.querySelector( '.pager__summary--bold:nth-last-of-type(2)', )!.innerText; diff --git a/src/router/index.ts b/src/router/index.ts index ce4cee7..9679001 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -13,6 +13,7 @@ const routeObj: Record<'sidepanel' | 'options', RouteRecordRaw[]> = { { path: '/amazon', component: () => import('~/options/views/AmazonResultTable.vue') }, { path: '/amazon-reviews', component: () => import('~/options/views/AmazonReviews.vue') }, { path: '/homedepot', component: () => import('~/options/views/HomedepotResultTable.vue') }, + { path: '/homedepot-reviews', component: () => import('~/options/views/HomedepotReviews.vue') }, { path: '/help', component: () => import('~/options/views/help/guide.md') }, ], sidepanel: [ diff --git a/src/sidepanel/views/HomedepotSidepanel.vue b/src/sidepanel/views/HomedepotSidepanel.vue index 3b7865a..2d04184 100644 --- a/src/sidepanel/views/HomedepotSidepanel.vue +++ b/src/sidepanel/views/HomedepotSidepanel.vue @@ -15,6 +15,14 @@ worker.on('detail-item-collected', ({ item }) => { time: new Date().toLocaleString(), }); }); +worker.on('review-collected', ({ OSMID, reviews }) => { + timelines.value.push({ + type: 'success', + title: `成功`, + content: `成功获取到${OSMID}的${reviews.length}条评论`, + time: new Date().toLocaleString(), + }); +}); const timelines = ref([]); diff --git a/src/storages/amazon.ts b/src/storages/amazon.ts index 3cc7bae..113092d 100644 --- a/src/storages/amazon.ts +++ b/src/storages/amazon.ts @@ -24,17 +24,11 @@ export const searchItems = useWebExtensionStorage('searchIte export const detailItems = useWebExtensionStorage>( 'detailItems', new Map(), - { - listenToStorageChanges: 'options', - }, ); export const reviewItems = useWebExtensionStorage>( 'reviewItems', new Map(), - { - listenToStorageChanges: 'options', - }, ); export const allItems = computed({ diff --git a/src/storages/homedepot.ts b/src/storages/homedepot.ts index b73d08d..8d4f20f 100644 --- a/src/storages/homedepot.ts +++ b/src/storages/homedepot.ts @@ -9,9 +9,11 @@ export const detailWorkerSettings = useWebExtensionStorage('homedepot-detail-wor export const detailItems = useWebExtensionStorage>( 'homedepot-details', new Map(), - { - listenToStorageChanges: 'options', - }, +); + +export const reviewItems = useWebExtensionStorage>( + 'homedepot-reviews', + new Map(), ); export const allItems = computed({ @@ -25,3 +27,25 @@ export const allItems = computed({ }, new Map()); }, }); + +export const allReviews = computed({ + get() { + const reviewItemMap = toRaw(reviewItems.value); + return Array.from( + reviewItemMap + .entries() + .map(([OSMID, reviews]) => + reviews.map<{ OSMID: string } & HomedepotReview>((r) => ({ ...r, OSMID })), + ), + ).flat(); + }, + set(newVal) { + reviewItems.value = newVal.reduce((m, c) => { + const { OSMID, ...review } = c; + const reveiws = m.get(OSMID) || []; + reveiws.push(review); + m.set(OSMID, reveiws); + return m; + }, new Map()); + }, +});