This commit is contained in:
johnathan 2025-06-06 13:49:27 +08:00
parent 9f296e337b
commit 127fb5866a
9 changed files with 407 additions and 128 deletions

View File

@ -1,7 +1,7 @@
{
"name": "azon-seeker",
"displayName": "Azon Seeker",
"version": "0.0.1",
"version": "0.2.0",
"private": true,
"description": "Starter modify by honestfox101",
"scripts": {
@ -12,6 +12,7 @@
"dev:web": "vite",
"dev:js": "npm run build:js -- --mode development",
"build": "cross-env NODE_ENV=production run-s clear build:web build:prepare build:background build:js",
"build:firefox": "cross-env NODE_ENV=production EXTENSION=firefox run-s clear build:web build:prepare build:background build:js",
"build:prepare": "esno scripts/prepare.ts",
"build:background": "vite build --config vite.config.background.mts",
"build:web": "vite build",

View File

@ -7,8 +7,9 @@ const props = defineProps<{ model: AmazonDetailItem }>();
const modal = useModal();
const handleLoadMore = () => {
const asin = props.model.asin;
modal.create({
title: `${props.model.asin}全部评论`,
title: `${asin}评论`,
preset: 'card',
style: {
width: '80vw',
@ -16,7 +17,7 @@ const handleLoadMore = () => {
},
content: () =>
h(ReviewPreview, {
asin: props.model.asin,
asin,
}),
});
};
@ -47,11 +48,9 @@ const handleLoadMore = () => {
{{ model.category2?.rank || '-' }}
</n-descriptions-item>
<n-descriptions-item label="图片链接" :span="4">
<div v-for="link in model.imageUrls">
{{ link }}
</div>
<image-link v-for="link in model.imageUrls" :url="link" />
</n-descriptions-item>
<n-descriptions-item
<!-- <n-descriptions-item
v-if="model.topReviews && model.topReviews.length > 0"
label="精选评论"
:span="4"
@ -69,7 +68,7 @@ const handleLoadMore = () => {
更多评论
</n-button>
</div>
</n-descriptions-item>
</n-descriptions-item> -->
</n-descriptions>
</div>
</template>

View File

@ -0,0 +1,78 @@
<script setup lang="ts">
const props = defineProps<{
url: string;
}>();
const message = useMessage();
const cached = ref<string | undefined>();
watch(
() => props.url,
(newVal, oldVal) => {
if (newVal !== oldVal && cached.value) {
URL.revokeObjectURL(cached.value);
cached.value = undefined;
}
},
);
const loadImage = () => {
if (cached.value) {
return;
}
fetch(props.url)
.then((response) => {
if (!response.ok) {
throw new Error('加载失败');
}
return response.blob();
})
.then((blob) => {
cached.value = URL.createObjectURL(blob);
})
.catch((_error) => {
message.error(`加载图片失败`);
});
};
onUnmounted(() => {
if (cached.value) {
URL.revokeObjectURL(cached.value);
cached.value = undefined;
}
});
</script>
<template>
<div>
<n-popover
@update:show="(v) => v && loadImage()"
trigger="hover"
placement="right"
:delay="1000"
:duration="1000"
>
<template #trigger>
<span class="link-text">{{ url }}</span>
</template>
<img v-if="cached" :src="cached" />
<n-text v-else>加载中...</n-text>
</n-popover>
</div>
</template>
<style lang="scss" scoped>
.link-text {
cursor: default;
font-family: v-mono;
&:hover {
text-decoration: underline;
}
}
img {
max-width: 50vw;
max-height: 50vh;
}
</style>

View File

@ -1,20 +1,23 @@
<script setup lang="ts">
import { NButton } from 'naive-ui';
import { NButton, NSpace } from 'naive-ui';
import type { TableColumn } from 'naive-ui/es/data-table/src/interface';
import { exportToXLSX, Header, importFromXLSX } from '~/logic/data-io';
import type { AmazonDetailItem, AmazonItem } from '~/logic/page-worker/types';
import { allItems } from '~/logic/storage';
import type { AmazonDetailItem, AmazonItem, AmazonReview } from '~/logic/page-worker/types';
import { allItems, reviewItems } from '~/logic/storage';
import DetailDescription from './DetailDescription.vue';
import ReviewPreview from './ReviewPreview.vue';
const message = useMessage();
const modal = useModal();
const page = reactive({ current: 1, size: 10 });
const filter = reactive({
keywords: null as string | null,
const defaultFilter = {
keywords: undefined as string | undefined,
search: '',
detailOnly: false,
});
};
const filter = reactive(defaultFilter);
const filterFormItems = computed(() => {
const records = allItems.value;
return [
@ -38,11 +41,7 @@ const filterFormItems = computed(() => {
});
const onFilterReset = () => {
Object.assign(filter, {
keywords: null as string | null,
search: '',
detailOnly: false,
});
Object.assign(filter, defaultFilter);
};
const columns: (TableColumn<AmazonItem> & { hidden?: boolean })[] = [
@ -96,23 +95,53 @@ const columns: (TableColumn<AmazonItem> & { hidden?: boolean })[] = [
minWidth: 160,
},
{
title: '链接',
key: 'link',
title: '查看',
key: 'actions',
minWidth: 100,
render(row) {
return h(
NButton,
return h(NSpace, {}, () =>
[
{
type: 'primary',
text: true,
size: 'small',
onClick: async () => {
await browser.tabs.create({
text: '评论',
disabled: !reviewItems.value.has(row.asin),
onClick: () => {
const asin = row.asin;
modal.create({
title: `${asin}评论`,
preset: 'card',
style: {
width: '80vw',
height: '85vh',
},
content: () =>
h(ReviewPreview, {
asin,
}),
});
},
},
{
text: '链接',
onClick: () => {
browser.tabs.create({
active: true,
url: row.link,
});
},
},
() => '前往',
].map(({ text, onClick, disabled }) =>
h(
NButton,
{
type: 'primary',
text: true,
size: 'small',
disabled: disabled,
onClick: onClick,
},
() => text,
),
),
);
},
},
@ -133,6 +162,7 @@ const itemView = computed<{ records: AmazonItem[]; pageCount: number; origin: Am
);
const extraHeaders: Header[] = [
{ prop: 'link', label: '商品链接' },
{
prop: 'hasDetail',
label: '有详情',
@ -153,6 +183,36 @@ const extraHeaders: Header[] = [
},
];
const reviewHeaders: Header[] = [
{ prop: 'asin', label: 'ASIN' },
{ prop: 'username', label: '用户名' },
{ prop: 'title', label: '标题' },
{ prop: 'rating', label: '评分' },
{ prop: 'content', label: '内容' },
{ prop: 'dateInfo', label: '日期' },
{
prop: 'imageSrc',
label: '图片链接',
formatOutputValue: (val?: string[]) => val?.join(';'),
parseImportValue: (val?: string) => val?.split(';'),
},
];
const getItemHeaders = () => {
return columns
.filter((col: Record<string, any>) => col.key !== 'actions')
.reduce(
(p, v: Record<string, any>) => {
if ('key' in v && 'title' in v) {
p.push({ label: v.title, prop: v.key });
}
return p;
},
[] as { label: string; prop: string }[],
)
.concat(extraHeaders) as Header[];
};
const filterItemData = (data: AmazonItem[]): AmazonItem[] => {
const { search, detailOnly, keywords } = filter;
if (search.trim() !== '') {
@ -172,41 +232,52 @@ const filterItemData = (data: AmazonItem[]): AmazonItem[] => {
};
const handleExport = async () => {
const headers: Header[] = columns
.reduce(
(p, v: Record<string, any>) => {
if ('key' in v && 'title' in v) {
p.push({ label: v.title, prop: v.key });
}
return p;
},
[] as { label: string; prop: string }[],
)
.concat(extraHeaders);
const { origin: data } = itemView.value;
exportToXLSX(data, { headers });
const itemHeaders = getItemHeaders();
const items = toRaw(itemView.value).origin;
const asins = new Set(items.map((e) => e.asin));
const reviews = toRaw(reviewItems.value)
.entries()
.filter(([asin]) => asins.has(asin))
.reduce<(AmazonReview & { asin: string })[]>((a, [asin, reviews]) => {
a.push(...reviews.map((r) => ({ asin, ...r })));
return a;
}, []);
const sheet1 = exportToXLSX(items, { headers: itemHeaders, asWorkSheet: true });
const wb = sheet1.toWorkbook('items');
const sheet2 = exportToXLSX(reviews, { headers: reviewHeaders, asWorkSheet: true });
wb.addSheet('reviews', sheet2);
wb.exportFile(`Items ${dayjs().format('YYYY-MM-DD')}.xlsx`);
message.info('导出完成');
};
const handleImport = async (file: File) => {
const headers: Header[] = columns
.reduce(
(p, v: Record<string, any>) => {
if ('key' in v && 'title' in v) {
p.push({ label: v.title, prop: v.key });
const itemHeaders = getItemHeaders();
const wb = await importFromXLSX(file, { asWorkBook: true });
const sheet1 = wb.getSheet(0);
const items = sheet1.toJson<AmazonItem>({ headers: itemHeaders });
allItems.value = items;
if (wb.sheetCount > 1) {
const sheet2 = wb.getSheet(1);
const reviews = sheet2.toJson<AmazonReview & { asin?: string }>({ headers: reviewHeaders });
reviewItems.value = reviews.reduce((m, r) => {
const asin = r.asin!;
delete r.asin;
m.has(asin) || m.set(asin, []);
const arr = m.get(asin)!;
arr.push(r);
return m;
}, new Map<string, AmazonReview[]>());
}
return p;
},
[] as { label: string; prop: string }[],
)
.concat(extraHeaders);
const importedData = await importFromXLSX<AmazonItem>(file, { headers });
allItems.value = importedData; //
message.info(`成功导入 ${file.name} 文件 ${importedData.length} 条数据`);
message.info(`成功导入 ${file.name} 文件`);
};
const handleClearData = async () => {
allItems.value = [];
reviewItems.value = new Map();
};
</script>
@ -229,6 +300,7 @@ const handleClearData = async () => {
size="small"
placeholder="输入文本过滤结果"
round
clearable
style="min-width: 230px"
/>
<control-strip

View File

@ -13,9 +13,16 @@ defineProps<{
<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 class="image-link-container" v-if="model.imageSrc">
<image-link v-for="link in model.imageSrc" :url="link" />
</div>
<div style="color: gray; font-size: smaller">{{ model.dateInfo }}</div>
</div>
</template>
<style lang="scss" scoped>
.image-link-container {
display: flex;
flex-direction: column;
}
</style>

View File

@ -90,7 +90,8 @@ const handleImport = async (file: File) => {
};
const handleExport = () => {
exportToXLSX(allReviews, { headers });
const fileName = `${props.asin}Reviews${dayjs().format('YYYY-MM-DD')}.xlsx`;
exportToXLSX(allReviews, { headers, fileName });
message.info('导出完成');
};
</script>

View File

@ -1,4 +1,99 @@
import { utils, read, writeFileXLSX } from 'xlsx';
import { utils, read, writeFileXLSX, WorkSheet, WorkBook } from 'xlsx';
class Worksheet {
readonly _raw: WorkSheet;
constructor(ws: WorkSheet) {
this._raw = ws;
}
static fromJson(data: Record<string, unknown>[], options: { headers?: Header[] } = {}) {
const {
headers = data.length > 0
? Object.keys(data[0]).map((k) => ({ label: k, prop: k }) as Header)
: [],
} = options;
const rows = data.map((item) => {
const row: Record<string, unknown> = {};
headers.forEach((header) => {
const value = getAttribute(item, header.prop);
if (header.formatOutputValue) {
row[header.label] = header.formatOutputValue(value);
} else if (['string', 'number', 'bigint', 'boolean'].includes(typeof value)) {
row[header.label] = value;
} else {
row[header.label] = JSON.stringify(value);
}
});
return row;
});
const ws = utils.json_to_sheet(rows, {
header: headers.map((h) => h.label),
});
ws['!autofilter'] = {
ref: utils.encode_range({ c: 0, r: 0 }, { c: headers.length - 1, r: rows.length }),
}; // Use Auto Filter https://github.com/SheetJS/sheetjs/issues/472#issuecomment-292852308
return new Worksheet(ws);
}
toJson<T>(options: { headers?: Header[] } = {}) {
const { headers } = options;
let jsonData = utils.sheet_to_json<Record<string, unknown>>(this._raw);
if (headers) {
jsonData = jsonData.map((item) => {
const mappedItem: Record<string, unknown> = {};
headers.forEach((header) => {
const value = header.parseImportValue
? header.parseImportValue(item[header.label])
: item[header.label];
setAttribute(mappedItem, header.prop, value);
});
return mappedItem;
});
}
return jsonData as T[];
}
toWorkbook(sheetName?: string) {
const wb = new Workbook(utils.book_new());
wb.addSheet(sheetName || 'Sheet1', this);
return wb;
}
}
class Workbook {
readonly _raw: WorkBook;
constructor(wb: WorkBook) {
this._raw = wb;
}
get sheetCount() {
return this._raw.SheetNames.length;
}
static fromArrayBuffer(bf: ArrayBuffer) {
const data = new Uint8Array(bf);
const wb = read(data, { type: 'array' });
return new Workbook(wb);
}
getSheet(index: number) {
const sheetName = this._raw.SheetNames[index];
return new Worksheet(this._raw.Sheets[sheetName]);
}
addSheet(name: string, sheet: Worksheet) {
utils.book_append_sheet(this._raw, sheet._raw, name);
}
exportFile(fileName: string) {
writeFileXLSX(this._raw, fileName, { bookType: 'xlsx', type: 'binary', compression: true });
}
}
function getAttribute<T extends unknown>(
obj: Record<string, unknown>,
@ -40,49 +135,45 @@ export type Header = {
formatOutputValue?: (val: any) => any;
};
export type ExportBaseOptions = {
fileName?: string;
headers?: Header[];
};
export type ImportBaseOptions = {
headers?: Header[];
};
/**
* XLSX文件
* XLSX
* @param data
* @param options
*/
export function exportToXLSX(
data: Record<string, unknown>[],
options: {
fileName?: string;
headers?: Header[];
} = {},
): void {
if (!data.length) {
return;
}
options?: ExportBaseOptions & { asWorkSheet?: false },
): void;
export function exportToXLSX(
data: Record<string, unknown>[],
options: Omit<ExportBaseOptions, 'fileName'> & { asWorkSheet: true },
): Worksheet;
export function exportToXLSX(
data: Record<string, unknown>[],
options: ExportBaseOptions & { asWorkSheet?: boolean } = {},
) {
const {
headers,
fileName = `export_${new Date().toISOString().slice(0, 10)}.xlsx`,
asWorkSheet,
} = options;
const headers: Header[] =
options.headers || Object.keys(data[0]).map((k) => ({ label: k, prop: k }));
const rows = data.map((item) => {
const row: Record<string, unknown> = {};
headers.forEach((header) => {
const value = getAttribute(item, header.prop);
if (header.formatOutputValue) {
row[header.label] = header.formatOutputValue(value);
} else if (['string', 'number', 'bigint', 'boolean'].includes(typeof value)) {
row[header.label] = value;
} else {
row[header.label] = JSON.stringify(value);
}
});
return row;
});
const worksheet = Worksheet.fromJson(data, { headers: headers });
const worksheet = utils.json_to_sheet(rows, {
header: headers.map((h) => h.label),
});
worksheet['!autofilter'] = {
ref: utils.encode_range({ c: 0, r: 0 }, { c: headers.length - 1, r: rows.length }),
}; // Use Auto Filter https://github.com/SheetJS/sheetjs/issues/472#issuecomment-292852308
const workbook = utils.book_new();
utils.book_append_sheet(workbook, worksheet, 'Sheet1');
const fileName = options.fileName || `export_${new Date().toISOString().slice(0, 10)}.xlsx`;
writeFileXLSX(workbook, fileName, { bookType: 'xlsx', type: 'binary', compression: true });
if (asWorkSheet) {
return worksheet;
}
const workbook = worksheet.toWorkbook();
workbook.exportFile(fileName);
}
/**
@ -93,41 +184,34 @@ export function exportToXLSX(
*/
export async function importFromXLSX<T extends Record<string, unknown>>(
file: File,
options: { headers?: Header[] } = {},
): Promise<T[]> {
options?: ImportBaseOptions,
): Promise<T[]>;
export async function importFromXLSX(file: File, options: { asWorkBook: true }): Promise<Workbook>;
export async function importFromXLSX<T extends Record<string, unknown>>(
file: File,
options: ImportBaseOptions & { asWorkBook?: boolean } = {},
) {
const { headers, asWorkBook } = options;
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (event) => {
try {
const data = new Uint8Array(event.target?.result as ArrayBuffer);
const workbook = read(data, { type: 'array' });
const sheetName = workbook.SheetNames[0]; // 默认读取第一个工作表
const worksheet = workbook.Sheets[sheetName];
let jsonData = utils.sheet_to_json<T>(worksheet);
if (options.headers) {
jsonData = jsonData.map((item) => {
const mappedItem: Record<string, unknown> = {};
options.headers?.forEach((header) => {
const value = header.parseImportValue
? header.parseImportValue(item[header.label])
: item[header.label];
setAttribute(mappedItem, header.prop, value);
});
return mappedItem as T;
});
const wb = Workbook.fromArrayBuffer(event.target?.result as ArrayBuffer);
if (asWorkBook) {
resolve(wb);
}
const ws = wb.getSheet(0); // 默认读取第一个工作表
const jsonData = ws.toJson<T>({ headers });
resolve(jsonData);
} catch (error) {
reject(error);
}
};
reader.onerror = (error) => {
reject(error);
};
reader.readAsArrayBuffer(file);
});
}

View File

@ -95,7 +95,7 @@ export class AmazonSearchPageInjector extends BaseInjector {
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)',
'.puis-card-container',
) as unknown as HTMLDivElement[],
).filter((e) => e.getClientRects().length > 0);
const linkObjs = items.reduce<
@ -124,7 +124,7 @@ export class AmazonSearchPageInjector extends BaseInjector {
default:
break;
}
data = data && data.filter((r) => new URL(r.link).pathname.includes('/dp/'));
data = data && data.filter((r) => new URL(r.link).pathname.includes('/dp/')); // No advertisement only
return data;
}
@ -183,7 +183,7 @@ export class AmazonDetailPageInjector extends BaseInjector {
return this.run(async () => {
const title = document.querySelector<HTMLElement>('#title')!.innerText;
const price = document.querySelector<HTMLElement>(
'.a-price:not(.a-text-price) .a-offscreen',
'.aok-offscreen, .a-price:not(.a-text-price) .a-offscreen',
)?.innerText;
return { title, price };
});
@ -313,7 +313,14 @@ export class AmazonDetailPageInjector extends BaseInjector {
commentNode.querySelectorAll<HTMLImageElement>(
'.review-image-tile-section img[src] img[src]',
),
).map((e) => e.getAttribute('src')!);
).map((e) => {
const url = new URL(e.getAttribute('src')!);
const paths = url.pathname.split('/');
const chunks = paths[paths.length - 1].split('.');
paths[paths.length - 1] = `${chunks[0]}.${chunks[chunks.length - 1]}`;
url.pathname = paths.join('/');
return url.toString();
});
items.push({ id, username, title, rating, dateInfo, content, imageSrc });
}
return items;
@ -387,7 +394,14 @@ export class AmazonReviewPageInjector extends BaseInjector {
)!.innerText;
const imageSrc = Array.from(
commentNode.querySelectorAll<HTMLImageElement>('.review-image-tile-section img[src]'),
).map((e) => e.getAttribute('src')!);
).map((e) => {
const url = new URL(e.getAttribute('src')!);
const paths = url.pathname.split('/');
const chunks = paths[paths.length - 1].split('.');
paths[paths.length - 1] = `${chunks[0]}.${chunks[chunks.length - 1]}`;
url.pathname = paths.join('/');
return url.toString();
});
items.push({ id, username, title, rating, dateInfo, content, imageSrc });
}
return items;

View File

@ -18,3 +18,26 @@ body,
hover:opacity-100 hover:text-teal-600;
font-size: 0.9em;
}
* {
scrollbar-width: thin;
scrollbar-color: #e2e8f0 #f7fafc;
}
/* For Webkit browsers */
*::-webkit-scrollbar {
width: 8px;
height: 8px;
background: #f7fafc;
}
*::-webkit-scrollbar-thumb {
background: linear-gradient(135deg, #cbd5e1 40%, #a0aec0 100%);
border-radius: 8px;
border: 2px solid #f7fafc;
}
*::-webkit-scrollbar-track {
background: #f7fafc;
border-radius: 8px;
}