This commit is contained in:
Johnathan 2025-07-25 16:55:43 +08:00
parent 296f6687df
commit 2bafb403ea
16 changed files with 319 additions and 90 deletions

View File

@ -1,7 +1,7 @@
{
"name": "azon-seeker",
"displayName": "Azon Seeker",
"version": "0.6.0",
"version": "0.7.0",
"private": true,
"description": "Starter modify by honestfox101",
"scripts": {

View File

@ -14,6 +14,7 @@ const options: { label: string; value: string }[] = [
{ label: 'Amazon Review', value: '/amazon-reviews' },
{ label: 'Homedepot', value: '/homedepot' },
{ label: 'Homedepot Review', value: '/homedepot-reviews' },
{ label: 'Lowes', value: '/lowes' },
];
watch(opt, (val) => {
@ -30,6 +31,9 @@ watch(opt, (val) => {
case '/homedepot-reviews':
site.value = 'homedepot';
break;
case '/lowes':
site.value = 'lowes';
break;
default:
break;
}

View File

@ -94,7 +94,7 @@ const extraHeaders: Header[] = [
];
const filteredData = computed(() => {
let data = allItems.value;
let data = toRaw(allItems.value);
if (filter.value.timeRange) {
const start = dayjs(filter.value.timeRange[0]);
const end = dayjs(filter.value.timeRange[1]);

View File

@ -0,0 +1,201 @@
<script setup lang="tsx">
import type { TableColumn } from '~/components/ResultTable.vue';
import { useExcelHelper } from '~/composables/useExcelHelper';
import type { Header } from '~/logic/excel';
import { allItems } from '~/storages/lowes';
const message = useMessage();
const excelHelper = useExcelHelper();
const filter = ref({ timeRange: null as [number, number] | null });
const resetFilter = () => {
filter.value = { timeRange: null };
};
const columns: TableColumn[] = [
{
title: '品牌名称',
key: 'brandName',
minWidth: 120,
},
{
title: 'Item #',
key: 'itemSeries',
},
{
title: 'Model #',
key: 'modelSeries',
},
{
title: '标题',
key: 'title',
},
{
title: '价格',
key: 'price',
minWidth: 80,
},
{
title: '评分',
key: 'rate',
minWidth: 60,
},
{
title: '评论数',
key: 'reviewCount',
minWidth: 75,
},
{
title: '获取日期',
key: 'timestamp',
minWidth: 150,
},
{
title: '销量信息',
key: 'boughtInfo',
minWidth: 150,
},
{
title: '操作',
key: 'actions',
render(row: (typeof allItems.value)[0]) {
return (
<n-space>
<n-button
type="primary"
text
onClick={() => {
browser.tabs.create({
active: true,
url: row.link,
});
}}
>
前往
</n-button>
</n-space>
);
},
},
];
const extraHeaders: Header[] = [
{
label: '商品链接',
prop: 'link',
},
{
label: '主图链接',
prop: 'mainImageUrl',
},
];
const filteredData = computed(() => {
let data = toRaw(allItems.value);
if (filter.value.timeRange) {
const start = dayjs(filter.value.timeRange[0]);
const end = dayjs(filter.value.timeRange[1]);
data = data.filter(
(r) => dayjs(r.timestamp).diff(start) >= 0 && dayjs(r.timestamp).diff(end) <= 0,
);
}
return data;
});
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 Header[])
.concat(extraHeaders);
};
const handleClearData = () => {
allItems.value = [];
};
const handleImport = async (file: File) => {
const itemHeaders = getItemHeaders();
const [dataFragment] = await excelHelper.importFile(file, [itemHeaders]);
allItems.value = dataFragment.data as typeof allItems.value;
message.info(`成功导入 ${file.name} 文件`);
};
const handleExport = async (opt: 'cloud' | 'local') => {
const itemHeaders = getItemHeaders();
const fragments = [{ data: filteredData.value, imageColumn: '主图链接', headers: itemHeaders }];
await excelHelper.exportFile(fragments, { cloud: opt === 'cloud' });
};
</script>
<template>
<div class="result-table">
<result-table :records="filteredData" :columns="columns">
<template #header>
<n-space align="center">
<h3 class="header-text">Lowes 数据</h3>
</n-space>
</template>
<template #header-extra>
<control-strip round @clear="handleClearData" @import="handleImport">
<template #exporter>
<export-panel @export-file="handleExport" />
</template>
<template #filter>
<div class="filter-panel">
<div>过滤条件</div>
<n-form label-placement="left" :label-width="80" label-align="center">
<n-form-item label="采集时间:">
<n-date-picker
type="datetimerange"
v-model:value="filter.timeRange"
></n-date-picker>
</n-form-item>
</n-form>
<n-space justify="end">
<n-button @click="resetFilter">重置</n-button>
</n-space>
</div>
</template>
</control-strip>
</template>
</result-table>
</div>
</template>
<style scoped lang="scss">
.result-table {
width: 100%;
}
.header-text {
padding: 0px;
margin: 0px;
}
.expoter-progress-panel {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
font-size: 18px;
padding: 10px;
gap: 15px;
cursor: wait;
}
.filter-panel {
min-width: 200px;
max-width: 500px;
& > div:first-of-type {
font-size: 20px;
margin-bottom: 20px;
}
}
</style>

View File

@ -49,11 +49,11 @@ class HomedepotWorkerImpl
return;
}
await injector.waitForReviewLoad();
const reviews = await injector.getReviews();
await this.emit('review-collected', { OSMID, reviews });
while (await injector.tryJumpToNextPage()) {
const reviews = await injector.getReviews();
await this.emit('review-collected', { OSMID, reviews });
let reviews = await injector.getReviews();
reviews.length > 0 && (await this.emit('review-collected', { OSMID, reviews }));
while ((await injector.tryJumpToNextPage()) && reviews.length > 0) {
reviews = await injector.getReviews();
reviews.length > 0 && (await this.emit('review-collected', { OSMID, reviews }));
}
setTimeout(() => {
browser.tabs.remove(tab.id!);

View File

@ -31,8 +31,12 @@ class LowesWorkerImpl
const injector = new LowesDetailPageInjector(tab);
await injector.waitForPageLoad();
const baseInfo = await injector.getBaseInfo();
await this.emit('detail-item-collected', { item: { ...baseInfo, link: url } });
baseInfo &&
(await this.emit('detail-item-collected', {
item: { ...baseInfo, timestamp: dayjs().format('YYYY/M/D HH:mm:ss'), link: url },
}));
progress && progress(remains);
await new Promise((resolve) => setTimeout(resolve, 1000));
setTimeout(() => browser.tabs.remove(tab.id!), 1500);
}
}

View File

@ -11,7 +11,7 @@ export function usePageWorker(
settings?: HomedepotWorkerSettings,
): ReturnType<typeof useHomedepotWorker>;
export function usePageWorker(
type: 'homedepot',
type: 'lowes',
settings?: LowesWorkerSettings,
): ReturnType<typeof useLowesWorker>;
export function usePageWorker(type: Website, settings: any) {

View File

@ -15,7 +15,7 @@ export class AmazonSearchPageInjector extends BaseInjector {
}
while (true) {
await new Promise((resolve) => setTimeout(resolve, 500 + ~~(500 * Math.random())));
const spins = Array.from(document.querySelectorAll<HTMLDivElement>('.a-spinner')).filter(
const spins = Array.from(document.querySelectorAll<HTMLElement>('.a-spinner')).filter(
(e) => e.getClientRects().length > 0,
);
if (spins.length === 0) {
@ -28,7 +28,7 @@ export class AmazonSearchPageInjector extends BaseInjector {
public async getPagePattern() {
return this.run(async () => {
return Array.from(
document.querySelectorAll<HTMLDivElement>(
document.querySelectorAll<HTMLElement>(
'.puisg-row:has(.a-section.a-spacing-small.a-spacing-top-small:not(.a-text-right))',
),
).filter((e) => e.getClientRects().length > 0).length > 0
@ -44,7 +44,7 @@ export class AmazonSearchPageInjector extends BaseInjector {
case 'pattern-1':
data = await this.run(async () => {
const items = Array.from(
document.querySelectorAll<HTMLDivElement>(
document.querySelectorAll<HTMLElement>(
'.puisg-row:has(.a-section.a-spacing-small.a-spacing-top-small:not(.a-text-right))',
),
).filter((e) => e.getClientRects().length > 0);
@ -77,9 +77,9 @@ export class AmazonSearchPageInjector extends BaseInjector {
case 'pattern-2':
data = await this.run(async () => {
const items = Array.from(
document.querySelectorAll<HTMLDivElement>(
document.querySelectorAll<HTMLElement>(
'.puis-card-container',
) as unknown as HTMLDivElement[],
) as unknown as HTMLElement[],
).filter((e) => e.getClientRects().length > 0);
const linkObjs = items.reduce<
Pick<AmazonSearchItem, 'link' | 'title' | 'imageSrc' | 'price'>[]
@ -113,9 +113,7 @@ export class AmazonSearchPageInjector extends BaseInjector {
public async getCurrentPage() {
return this.run(async () => {
const node = document.querySelector<HTMLDivElement>(
'.s-pagination-item.s-pagination-selected',
);
const node = document.querySelector<HTMLElement>('.s-pagination-item.s-pagination-selected');
return node ? Number(node.innerText) : 1;
});
}
@ -228,7 +226,7 @@ export class AmazonDetailPageInjector extends BaseInjector {
null,
XPathResult.FIRST_ORDERED_NODE_TYPE,
null,
).singleNodeValue as HTMLDivElement | null;
).singleNodeValue as HTMLElement | null;
if (targetNode) {
targetNode.scrollIntoView({ behavior: 'smooth', block: 'center' });
return targetNode.innerText;
@ -241,9 +239,9 @@ export class AmazonDetailPageInjector extends BaseInjector {
/**获取图像链接 */
public async getImageUrls() {
return this.run(async () => {
const overlay = document.querySelector<HTMLDivElement>('.overlayRestOfImages');
const overlay = document.querySelector<HTMLElement>('.overlayRestOfImages');
if (overlay) {
if (document.querySelector<HTMLDivElement>('#ivThumbs')!.getClientRects().length === 0) {
if (document.querySelector<HTMLElement>('#ivThumbs')!.getClientRects().length === 0) {
overlay.click();
await new Promise((resolve) => setTimeout(resolve, 1000));
}
@ -272,7 +270,7 @@ export class AmazonDetailPageInjector extends BaseInjector {
/**获取精选评论 */
public async getTopReviews() {
return this.run(async () => {
const targetNode = document.querySelector<HTMLDivElement>('.cr-widget-FocalReviews');
const targetNode = document.querySelector<HTMLElement>('.cr-widget-FocalReviews');
if (!targetNode) {
return [];
}
@ -288,22 +286,22 @@ export class AmazonDetailPageInjector extends BaseInjector {
);
const items: AmazonReview[] = [];
for (let i = 0; i < xResult.snapshotLength; i++) {
const commentNode = xResult.snapshotItem(i) as HTMLDivElement | null;
const commentNode = xResult.snapshotItem(i) as HTMLElement | null;
if (!commentNode) {
continue;
}
const id = commentNode.id.split('-')[0];
const username = commentNode.querySelector<HTMLDivElement>('.a-profile-name')!.innerText;
const title = commentNode.querySelector<HTMLDivElement>(
const username = commentNode.querySelector<HTMLElement>('.a-profile-name')!.innerText;
const title = commentNode.querySelector<HTMLElement>(
'[data-hook="review-title"] > span:not(.a-letter-space)',
)!.innerText;
const rating = commentNode.querySelector<HTMLDivElement>(
const rating = commentNode.querySelector<HTMLElement>(
'[data-hook*="review-star-rating"]',
)!.innerText;
const dateInfo = commentNode.querySelector<HTMLDivElement>(
const dateInfo = commentNode.querySelector<HTMLElement>(
'[data-hook="review-date"]',
)!.innerText;
const content = commentNode.querySelector<HTMLDivElement>(
const content = commentNode.querySelector<HTMLElement>(
'[data-hook="review-body"]',
)!.innerText;
const imageSrc = Array.from(
@ -446,22 +444,22 @@ export class AmazonReviewPageInjector extends BaseInjector {
);
const items: AmazonReview[] = [];
for (let i = 0; i < xResult.snapshotLength; i++) {
const commentNode = xResult.snapshotItem(i) as HTMLDivElement;
const commentNode = xResult.snapshotItem(i) as HTMLElement;
if (!commentNode) {
continue;
}
const id = commentNode.id.split('-')[0];
const username = commentNode.querySelector<HTMLDivElement>('.a-profile-name')!.innerText;
const title = commentNode.querySelector<HTMLDivElement>(
const username = commentNode.querySelector<HTMLElement>('.a-profile-name')!.innerText;
const title = commentNode.querySelector<HTMLElement>(
'[data-hook="review-title"] > span:not(.a-letter-space)',
)!.innerText;
const rating = commentNode.querySelector<HTMLDivElement>(
const rating = commentNode.querySelector<HTMLElement>(
'[data-hook*="review-star-rating"]',
)!.innerText;
const dateInfo = commentNode.querySelector<HTMLDivElement>(
const dateInfo = commentNode.querySelector<HTMLElement>(
'[data-hook="review-date"]',
)!.innerText;
const content = commentNode.querySelector<HTMLDivElement>(
const content = commentNode.querySelector<HTMLElement>(
'[data-hook="review-body"]',
)!.innerText;
const imageSrc = Array.from(
@ -490,7 +488,7 @@ export class AmazonReviewPageInjector extends BaseInjector {
).singleNodeValue as HTMLElement | null;
latestReview?.scrollIntoView({ behavior: 'smooth', block: 'center' });
await new Promise((resolve) => setTimeout(resolve, 1000));
const nextPageNode = document.querySelector<HTMLDivElement>(
const nextPageNode = document.querySelector<HTMLElement>(
'[data-hook="pagination-bar"] .a-pagination > *:nth-of-type(2)',
);
nextPageNode?.scrollIntoView({ behavior: 'smooth', block: 'center' });
@ -504,7 +502,7 @@ export class AmazonReviewPageInjector extends BaseInjector {
public async showStarsDropDownMenu() {
return this.run(async () => {
while (true) {
const dropdown = document.querySelector<HTMLDivElement>('#star-count-dropdown')!;
const dropdown = document.querySelector<HTMLElement>('#star-count-dropdown')!;
dropdown.scrollIntoView({ behavior: 'smooth', block: 'center' });
dropdown.click();
if (dropdown.getAttribute('aria-expanded') === 'true') {

View File

@ -63,23 +63,23 @@ export class HomedepotDetailPageInjector extends BaseInjector {
public getInfo() {
return this.run(async () => {
const link = document.location.toString();
const brandName = document.querySelector<HTMLDivElement>(
const brandName = document.querySelector<HTMLElement>(
`[data-component^="product-details:ProductDetailsBrandCollection"]`,
)?.innerText;
const title = document.querySelector<HTMLDivElement>(
const title = document.querySelector<HTMLElement>(
`[data-component^="product-details:ProductDetailsTitle"]`,
)!.innerText;
const price = document
.querySelector<HTMLDivElement>(`#standard-price`)!
.querySelector<HTMLElement>(`#standard-price`)!
.innerText.replaceAll('\n', '');
const rateEl = document.querySelector<HTMLDivElement>(
const rateEl = document.querySelector<HTMLElement>(
`[data-component^="ratings-and-reviews"] .sui-mr-1`,
);
const rate = rateEl ? /\d(\.\d)?/.exec(rateEl.innerText)![0] : undefined;
const reviewCount = rateEl
? Number(
/\d+/.exec(
document.querySelector<HTMLDivElement>(
document.querySelector<HTMLElement>(
`[data-component^="ratings-and-reviews"] [name="simple-rating"] + span`,
)!.innerText,
)![0],
@ -93,7 +93,7 @@ export class HomedepotDetailPageInjector extends BaseInjector {
document,
null,
XPathResult.FIRST_ORDERED_NODE_TYPE,
).singleNodeValue as HTMLDivElement | null;
).singleNodeValue as HTMLElement | null;
const [modelInfo] = /(?<=#\s).+/.exec(modelInfoEl?.innerText || '') || [];
return {
link,
@ -138,6 +138,17 @@ export class HomedepotDetailPageInjector extends BaseInjector {
public getReviews() {
return this.run(async () => {
const noReview = Boolean(
document.evaluate(
`//span[text() = 'Write the First Review']`,
document,
null,
XPathResult.FIRST_ORDERED_NODE_TYPE,
).singleNodeValue,
);
if (noReview) {
return [];
}
const elements = document.querySelectorAll('.review_item');
return Array.from(elements).map((root) => {
const title = root.querySelector<HTMLElement>('.review-content__title')!.innerText;

View File

@ -12,6 +12,7 @@ export class LowesDetailPageInjector extends BaseInjector {
if (document.readyState === 'complete') {
break;
}
await new Promise((resolve) => setTimeout(resolve, 1000));
}
});
}
@ -24,8 +25,8 @@ export class LowesDetailPageInjector extends BaseInjector {
document,
null,
XPathResult.FIRST_ORDERED_NODE_TYPE,
).singleNodeValue as HTMLDivElement | null;
const itemSeries = itemNumberEl?.innerText.replace('Item #', '').trim();
).singleNodeValue as HTMLElement | null;
const itemSeries = itemNumberEl?.innerText.replace('Item #', '').replace('|', '').trim();
// 获取Model #
const modelNumberEl = document.evaluate(
@ -33,35 +34,33 @@ export class LowesDetailPageInjector extends BaseInjector {
document,
null,
XPathResult.FIRST_ORDERED_NODE_TYPE,
).singleNodeValue as HTMLDivElement | null;
).singleNodeValue as HTMLElement | null;
const modelSeries = modelNumberEl?.innerText.replace('Model #', '').trim();
// 获取品牌名称
const brandName = (
document.evaluate(
`//h1[contains(@class, "product-brand-description")]/parent::*/parent::*/following-sibling::*[1]//a`,
document,
null,
XPathResult.FIRST_ORDERED_NODE_TYPE,
).singleNodeValue as HTMLDivElement
).innerText;
const brandName = document.querySelector<HTMLElement>(
'[data-component-name="RatingsNLinks"] a .label',
)?.innerText;
// 销量信息
const boughtInfo = document.querySelector<HTMLElement>(
'[data-component-name="ExclusiveBadge"]',
)?.innerText;
// 获取标题
const title = document.querySelector<HTMLDivElement>(
`h1.product-brand-description`,
)!.innerText;
const title = document.querySelector<HTMLElement>(`h1.product-brand-description`)!.innerText;
// 获取价格
const price = document
.querySelector<HTMLDivElement>(`.screen-reader`)!
.querySelector<HTMLElement>(`.screen-reader`)!
.innerText.replaceAll('\n', '');
// 获取评分
const rate = document.querySelector<HTMLDivElement>(`.avgrating`)?.innerText;
const rate = document.querySelector<HTMLElement>(`.avgrating`)?.innerText;
// 获取评价数量
const reviewCount = Number(
document.querySelector<HTMLDivElement>(`[data-testid="rating-trigger"] > div > div > span`)
document.querySelector<HTMLElement>(`[data-testid="rating-trigger"] > div > div > span`)
?.innerText || '0',
);
@ -77,6 +76,7 @@ export class LowesDetailPageInjector extends BaseInjector {
rate,
reviewCount,
mainImageUrl,
boughtInfo,
itemSeries,
modelSeries,
};

View File

@ -14,12 +14,14 @@ const routeObj: Record<'sidepanel' | 'options', RouteRecordRaw[]> = {
{ 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: '/lowes', component: () => import('~/options/views/LowesResultTable.vue') },
{ path: '/help', component: () => import('~/options/views/help/guide.md') },
],
sidepanel: [
{ path: '/', redirect: `/${site.value}` },
{ path: '/amazon', component: () => import('~/sidepanel/views/AmazonSidepanel.vue') },
{ path: '/homedepot', component: () => import('~/sidepanel/views/HomedepotSidepanel.vue') },
{ path: '/lowes', component: () => import('~/sidepanel/views/LowesSidepanel.vue') },
],
};
@ -29,11 +31,11 @@ export const router: Plugin = {
case 'sidepanel':
case 'options':
const routes = routeObj[appContext];
const router = createRouter({
const vueRouter = createRouter({
history: appContext === 'sidepanel' ? createMemoryHistory() : createWebHashHistory(),
routes,
});
app.use(router);
app.use(vueRouter);
default:
break;
}

View File

@ -27,6 +27,9 @@ watch(currentUrl, (newVal) => {
case 'www.homedepot.com':
site.value = 'homedepot';
break;
case 'www.lowes.com':
site.value = 'lowes';
break;
default:
break;
}
@ -41,6 +44,9 @@ watch(site, (newVal) => {
case 'homedepot':
router.push('/homedepot');
break;
case 'lowes':
router.push('/lowes');
break;
default:
break;
}

View File

@ -1,25 +1,16 @@
<script setup lang="ts">
import type { Timeline } from '~/components/ProgressReport.vue';
import { usePageWorker } from '~/page-worker';
import { detailInputText } from '~/storages/homedepot';
import { detailWorkerSettings } from '~/storages/homedepot';
import { detailInputText } from '~/storages/lowes';
const idInputRef = useTemplateRef('id-input');
const worker = usePageWorker('homedepot', { objects: ['detail'] });
const worker = usePageWorker('lowes', { objects: ['detail'] });
worker.on('detail-item-collected', ({ item }) => {
timelines.value.push({
type: 'success',
title: `成功`,
content: `成功获取到${item.OSMID}的商品信息`,
time: new Date().toLocaleString(),
});
});
worker.on('review-collected', ({ OSMID, reviews }) => {
timelines.value.push({
type: 'success',
title: `成功`,
content: `成功获取到${OSMID}${reviews.length}条评论`,
title: `信息采集`,
content: `成功获取到${item.link}的商品信息`,
time: new Date().toLocaleString(),
});
});
@ -50,7 +41,6 @@ const handleStart = async () => {
}
detailInputText.value = remains.join('\n');
},
review: detailWorkerSettings.value.review,
});
timelines.value.push({
type: 'info',
@ -68,8 +58,8 @@ const handleInterrupt = () => {
</script>
<template>
<div class="homedepot-sidepanel">
<header-title>Lowes</header-title>
<div class="lowes-sidepanel">
<header-title>Lowes Detail</header-title>
<div class="interative-section">
<id-input
v-model="detailInputText"
@ -81,19 +71,16 @@ const handleInterrupt = () => {
placeholder="输入URL"
validate-message="请输入格式正确的URL"
/>
<optional-button v-if="!worker.isRunning.value" round size="large" @click="handleStart">
<template #popover>
<div class="setting-panel">
<n-form :label-width="50" label-placement="left" :show-feedback="false">
<n-form-item label="评论:">
<n-switch v-model:value="detailWorkerSettings.review" />
</n-form-item>
</n-form>
</div>
</template>
<n-button
v-if="!worker.isRunning.value"
type="primary"
round
size="large"
@click="handleStart"
>
<n-icon :size="20"><ant-design-thunderbolt-outlined /></n-icon>
开始
</optional-button>
</n-button>
<n-button v-else round size="large" type="primary" @click="handleInterrupt">
<n-icon :size="20"><ant-design-thunderbolt-outlined /></n-icon>
停止
@ -107,7 +94,7 @@ const handleInterrupt = () => {
</template>
<style scoped lang="scss">
.homedepot-sidepanel {
.lowes-sidepanel {
display: flex;
flex-direction: column;
align-items: center;

View File

@ -1,6 +1,20 @@
import { useWebExtensionStorage } from '~/composables/useWebExtensionStorage';
export const detailInputText = useWebExtensionStorage('lowes-detail-input-text', '');
export const detailItems = useWebExtensionStorage<Map<string, LowesDetailItem>>(
'lowes-details',
new Map(),
);
export const allItems = computed({
get() {
return Array.from(detailItems.value.values());
},
set(newValue) {
detailItems.value = newValue.reduce((m, c) => {
m.set(c.link, c);
return m;
}, new Map());
},
});

View File

@ -1,8 +1,12 @@
declare type LowesDetailItem = {
link: string;
itemSeries?: string;
modelSeries?: string;
brandName?: string;
boughtInfo?: string;
title: string;
price: string;
timestamp: string;
rate?: string;
reviewCount?: number;
mainImageUrl: string;

View File

@ -1,5 +1,3 @@
/// <reference types="vitest" />
import { dirname, relative } from 'node:path';
import type { UserConfig } from 'vite';
import { defineConfig } from 'vite';