This commit is contained in:
johnathan 2025-05-30 15:43:56 +08:00
parent 9aeb12c7bf
commit 5ac0f7ad64
12 changed files with 297 additions and 104 deletions

View File

@ -39,6 +39,7 @@
"chokidar": "^4.0.3",
"cross-env": "^7.0.3",
"crx": "^5.0.1",
"dayjs": "^1.11.13",
"emittery": "^1.1.0",
"esno": "^4.8.0",
"fs-extra": "^11.2.0",

11
pnpm-lock.yaml generated
View File

@ -44,6 +44,9 @@ importers:
crx:
specifier: ^5.0.1
version: 5.0.1
dayjs:
specifier: ^1.11.13
version: 1.11.13
emittery:
specifier: ^1.1.0
version: 1.1.0
@ -2073,6 +2076,12 @@ packages:
integrity: sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==,
}
dayjs@1.11.13:
resolution:
{
integrity: sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==,
}
debounce@1.2.1:
resolution:
{
@ -7364,6 +7373,8 @@ snapshots:
date-fns@3.6.0: {}
dayjs@1.11.13: {}
debounce@1.2.1: {}
debug@2.6.9:

View File

@ -1,57 +1,73 @@
<script lang="ts" setup>
import type { AmazonDetailItem } from '~/logic/page-worker/types';
import { reviewItems } from '~/logic/storage';
import ReviewList from './ReviewList.vue';
const props = defineProps<{ model: AmazonDetailItem }>();
const modal = useModal();
const handleLoadMore = () => {
modal.create({
title: '评论',
preset: 'card',
style: {
width: '85vw',
height: '85vh',
},
content: () =>
h(ReviewList, {
asin: props.model.asin,
}),
});
};
</script>
<template>
<div class="detail-description">
<n-descriptions label-placement="left" bordered :column="4" label-style="min-width: 100px">
<n-descriptions-item label="ASIN" :span="2">
{{ props.model.asin }}
{{ model.asin }}
</n-descriptions-item>
<n-descriptions-item label="评价">
{{ props.model.rating || '-' }}
{{ model.rating || '-' }}
</n-descriptions-item>
<n-descriptions-item label="评论数">
{{ props.model.ratingCount || '-' }}
{{ model.ratingCount || '-' }}
</n-descriptions-item>
<n-descriptions-item label="大类">
{{ props.model.category1?.name || '-' }}
{{ model.category1?.name || '-' }}
</n-descriptions-item>
<n-descriptions-item label="排名">
{{ props.model.category1?.rank || '-' }}
{{ model.category1?.rank || '-' }}
</n-descriptions-item>
<n-descriptions-item label="小类">
{{ props.model.category2?.name || '-' }}
{{ model.category2?.name || '-' }}
</n-descriptions-item>
<n-descriptions-item label="排名">
{{ props.model.category2?.rank || '-' }}
{{ model.category2?.rank || '-' }}
</n-descriptions-item>
<n-descriptions-item label="图片链接" :span="4">
<div v-for="link in props.model.imageUrls">
<div v-for="link in model.imageUrls">
{{ link }}
</div>
</n-descriptions-item>
<n-descriptions-item
v-if="props.model.topReviews && props.model.topReviews.length > 0"
v-if="model.topReviews && model.topReviews.length > 0"
label="精选评论"
:span="4"
>
<n-scrollbar style="max-height: 500px">
<n-scrollbar style="max-height: 350px">
<div class="review-item-cotent">
<div v-for="review in props.model.topReviews" style="margin-bottom: 5px">
<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>
<template v-for="review in model.topReviews">
<review-card :model="review" />
<div style="height: 7px"></div>
</template>
</div>
</n-scrollbar>
<div class="review-item-footer">
<n-button size="small">Load More</n-button>
<n-button :disabled="!reviewItems.has(model.asin)" @click="handleLoadMore" size="small">
更多评论
</n-button>
</div>
</n-descriptions-item>
</n-descriptions>

View File

@ -0,0 +1,21 @@
<script lang="ts" setup>
import type { AmazonReview } from '~/logic/page-worker/types';
defineProps<{
model: AmazonReview;
}>();
</script>
<template>
<div class="review-card">
<h3 style="margin: 0">{{ model.username }}: {{ model.title }}</h3>
<n-rate readonly size="small" :default-value="Number(model.rating.split(' ')[0])" />
<div v-for="paragraph in model.content.split('\n')">
{{ paragraph }}
</div>
<template v-if="model.imageSrc">
<n-code :code="model.imageSrc.join('\n')" />
</template>
<div style="color: gray; font-size: smaller">{{ model.dateInfo }}</div>
</div>
</template>

View File

@ -0,0 +1,120 @@
<script lang="ts" setup>
import { useElementSize } from '@vueuse/core';
import type { AmazonReview } from '~/logic/page-worker/types';
import { reviewItems } from '~/logic/storage';
const props = defineProps<{ asin: string }>();
const containerRef = useTemplateRef('review-list');
const { height } = useElementSize(containerRef);
const allReviews = reviewItems.value.get(props.asin) || [];
const filter = reactive({
keywords: '',
});
const page = reactive({
current: 1,
pageSize: 5,
});
const view = computed(() => {
const offset = (page.current - 1) * page.pageSize;
const filteredData = filterData(allReviews);
const data = filteredData.slice(offset, offset + page.pageSize);
const pageCount = ~~(filteredData.length / page.pageSize);
return { data, pageCount, total: filteredData.length };
});
const filterData = (data: AmazonReview[]) => {
let filteredData = data;
if (filter.keywords) {
filteredData = data.filter((item) => {
const keywords = filter.keywords.toLowerCase();
return (
item.title.toLowerCase().includes(keywords) ||
item.content.toLowerCase().includes(keywords) ||
item.username.toLowerCase().includes(keywords)
);
});
}
return filteredData;
};
</script>
<template>
<div class="review-list" ref="review-list">
<div class="header">
<div class="header-section">
<n-space justify="start">
<n-input
v-model:value="filter.keywords"
style="width: 500px"
round
placeholder="输入关键词筛选评论"
/>
</n-space>
</div>
<div class="header-section"></div>
</div>
<n-scrollbar :style="{ maxHeight: `${height * 0.85}px`, minHeight: `${height * 0.85}px` }">
<div class="review-list-container" :style="{ minHeight: `${height * 0.8}px` }">
<template v-for="review in view.data">
<review-card :model="review" />
<div style="height: 7px" />
</template>
</div>
</n-scrollbar>
<div>
<n-space align="center">
<n-pagination
v-model:page="page.current"
v-model:page-size="page.pageSize"
:page-count="view.pageCount"
/>
<n-text>{{ view.total }}条评论</n-text>
</n-space>
</div>
</div>
</template>
<style lang="scss" scoped>
.review-list {
display: flex;
flex-direction: column;
gap: 10px;
justify-content: flex-start;
min-height: 100%;
max-height: 100%;
.header {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
.header-section {
display: flex;
flex-direction: row;
align-items: center;
max-width: 50%;
}
}
& > div:last-child {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
}
}
.review-list-container {
border: 1px solid #e0e0e0;
border-radius: 10px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
background: #fff;
padding: 16px;
}
</style>

View File

@ -1,32 +1,2 @@
/* eslint-disable no-console */
import App from './views/App.vue';
import { setupApp } from '~/logic/common-setup';
// 是否在ContentScript挂载Vue APP
const MOUNT_COMPONENT = false;
/**
* mount component to context window
*/
const mountComponent = () => {
const container = document.createElement('div');
container.id = __NAME__;
const root = document.createElement('div');
const styleEl = document.createElement('link');
const shadowDOM = container.attachShadow?.({ mode: __DEV__ ? 'open' : 'closed' }) || container;
styleEl.setAttribute('rel', 'stylesheet');
styleEl.setAttribute('href', browser.runtime.getURL('dist/contentScripts/index.css'));
shadowDOM.appendChild(styleEl);
shadowDOM.appendChild(root);
document.body.appendChild(container);
const app = createApp(App);
setupApp(app);
app.mount(root);
};
// Firefox `browser.tabs.executeScript()` requires scripts return a primitive value
(() => {
if (MOUNT_COMPONENT) {
mountComponent();
}
})();
(() => {})();

View File

@ -1,28 +0,0 @@
<script setup lang="ts">
import { useToggle } from '@vueuse/core';
const [show, toggle] = useToggle(false);
</script>
<template>
<main class="injected-content">
<button @click="toggle()">
<pixelarticons-power />
</button>
</main>
</template>
<style lang="scss" scoped>
.injected-content {
position: fixed;
bottom: 0;
right: 0;
z-index: 9999;
}
button {
border-radius: 0.5rem;
padding: 0.5rem;
text-align: center;
}
</style>

View File

@ -200,16 +200,23 @@ class AmazonPageWorkerImpl implements AmazonPageWorker {
@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 url = new URL(
`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(url.toString());
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;
await injector.waitForPageLoad();
for (let star = 1; star <= 5; star++) {
await injector.showStarsDropDownMenu();
await injector.selectStar(star);
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);

View File

@ -1,5 +1,10 @@
import { useWebExtensionStorage } from '~/composables/useWebExtensionStorage';
import type { AmazonDetailItem, AmazonItem, AmazonSearchItem } from './page-worker/types';
import type {
AmazonDetailItem,
AmazonItem,
AmazonReview,
AmazonSearchItem,
} from './page-worker/types';
export const keywordsList = useWebExtensionStorage<string[]>('keywordsList', ['']);
@ -17,15 +22,32 @@ export const detailItems = useWebExtensionStorage<Map<string, AmazonDetailItem>>
},
);
export const reviewItems = useWebExtensionStorage<Map<string, AmazonReview[]>>(
'reviewItems',
new Map(),
{
listenToStorageChanges: false,
},
);
export const allItems = computed({
get() {
const sItems = unref(searchItems);
const sItemSet = new Set(sItems.map((si) => si.asin));
const dItems = unref(detailItems);
return sItems.map<AmazonItem>((si) => {
const asin = si.asin;
const dItem = dItems.get(asin);
return dItems.has(asin) ? { ...si, ...dItem, hasDetail: true } : { ...si, hasDetail: false };
});
return sItems
.map<AmazonItem>((si) => {
const asin = si.asin;
const dItem = dItems.get(asin);
return dItems.has(asin)
? { ...si, ...dItem, hasDetail: true }
: { ...si, hasDetail: false };
})
.concat(
Array.from(dItems.values())
.filter((di) => !sItemSet.has(di.asin))
.map((di) => ({ ...di, link: `https://www.amazon.com/dp/${di.asin}`, hasDetail: true })),
);
},
set(newValue) {
const searchItemProps: (keyof AmazonSearchItem)[] = [
@ -39,12 +61,14 @@ export const allItems = computed({
'rank',
'createTime',
];
searchItems.value = newValue.map((row) => {
const entries: [string, unknown][] = Object.entries(row).filter(([key]) =>
searchItemProps.includes(key as keyof AmazonSearchItem),
);
return Object.fromEntries(entries) as AmazonSearchItem;
});
searchItems.value = newValue
.filter((row) => row.keywords)
.map((row) => {
const entries: [string, unknown][] = Object.entries(row).filter(([key]) =>
searchItemProps.includes(key as keyof AmazonSearchItem),
);
return Object.fromEntries(entries) as AmazonSearchItem;
});
const detailItemsProps: (keyof AmazonDetailItem)[] = [
'asin',
'title',

View File

@ -9,8 +9,11 @@ class BaseInjector {
this._tab = tab;
}
run<T>(func: (payload?: any) => Promise<T>, payload?: any): Promise<T> {
return exec(this._tab.id!, func, payload);
run<T, P extends Record<string, unknown>>(
func: (payload: P) => Promise<T>,
payload?: P,
): Promise<T> {
return exec(this._tab.id!, func, payload as P);
}
}
@ -324,7 +327,7 @@ export class AmazonReviewPageInjector extends BaseInjector {
await new Promise((resolve) => setTimeout(resolve, 1000));
while (true) {
const targetNode = document.querySelector(
'#cm_cr-review_list .reviews-content,ul[role="list"]:not(.histogram)',
'.reviews-content, #cm_cr-review_list ul[role="list"]:not(.histogram)',
);
targetNode?.scrollIntoView({ behavior: 'smooth', block: 'center' });
if (
@ -411,4 +414,34 @@ export class AmazonReviewPageInjector extends BaseInjector {
return ret;
});
}
public async showStarsDropDownMenu() {
return this.run(async () => {
while (true) {
const dropdown = document.querySelector<HTMLDivElement>('#star-count-dropdown')!;
dropdown.scrollIntoView({ behavior: 'smooth', block: 'center' });
dropdown.click();
if (dropdown.getAttribute('aria-expanded') === 'true') {
break;
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
});
}
public async selectStar(star: number) {
return this.run(
async ({ star }) => {
const starNode = document.evaluate(
`//ul[@role='listbox']/li/a[text()="${star} star only"]`,
document.body,
null,
XPathResult.FIRST_ORDERED_NODE_TYPE,
).singleNodeValue as HTMLAnchorElement;
starNode.click();
await new Promise((resolve) => setTimeout(resolve, 100));
},
{ star },
);
}
}

View File

@ -1,7 +1,8 @@
<script lang="ts" setup>
import { useLongTask } from '~/composables/useLongTask';
import pageWorker from '~/logic/page-worker';
import { reviewAsinInput } from '~/logic/storage';
import type { AmazonReview } from '~/logic/page-worker/types';
import { reviewAsinInput, reviewItems } from '~/logic/storage';
const worker = pageWorker.useAmazonPageWorker();
worker.channel.on('error', ({ message: msg }) => {
@ -11,6 +12,7 @@ worker.channel.on('error', ({ message: msg }) => {
time: new Date().toLocaleString(),
content: msg,
});
worker.stop();
});
worker.channel.on('item-review-collected', (ev) => {
timelines.value.push({
@ -19,6 +21,7 @@ worker.channel.on('item-review-collected', (ev) => {
time: new Date().toLocaleString(),
content: `获取到 ${ev.reviews.length} 条评价`,
});
updateReviews(ev);
});
const { isRunning, startTask } = useLongTask();
@ -65,6 +68,18 @@ const handleInterrupt = () => {
worker.stop();
message.info('已触发中断,正在等待当前任务完成。', { duration: 2000 });
};
const updateReviews = (params: { asin: string; reviews: AmazonReview[] }) => {
const { asin, reviews } = params;
const values = toRaw(reviewItems.value).get(asin) || [];
const ids = new Set(values.map((item) => item.id));
for (const review of reviews) {
ids.has(review.id) || values.push(review);
}
values.sort((a, b) => dayjs(b.dateInfo).valueOf() - dayjs(a.dateInfo).valueOf());
reviewItems.value.delete(asin);
reviewItems.value.set(asin, values);
};
</script>
<template>

View File

@ -29,11 +29,14 @@ export const sharedConfig: UserConfig = {
AutoImport({
imports: [
'vue',
{
dayjs: [['=', 'dayjs']],
},
{
'webextension-polyfill': [['=', 'browser']],
},
{
'naive-ui': ['useDialog', 'useMessage', 'useNotification', 'useLoadingBar'],
'naive-ui': ['useDialog', 'useMessage', 'useNotification', 'useLoadingBar', 'useModal'],
},
],
dts: r('src/auto-imports.d.ts'),