This commit is contained in:
johnathan 2025-06-24 16:21:18 +08:00
parent 2d29e5c95a
commit 4a12fc7ed3
24 changed files with 314 additions and 185 deletions

View File

@ -1,6 +1,4 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { AmazonDetailItem } from '~/logic/page-worker/types';
defineProps<{ model: AmazonDetailItem }>(); defineProps<{ model: AmazonDetailItem }>();
</script> </script>
@ -31,6 +29,9 @@ defineProps<{ model: AmazonDetailItem }>();
<n-descriptions-item label="图片链接" :span="4"> <n-descriptions-item label="图片链接" :span="4">
<image-link v-for="link in model.imageUrls" :url="link" /> <image-link v-for="link in model.imageUrls" :url="link" />
</n-descriptions-item> </n-descriptions-item>
<n-descriptions-item v-if="model.aplus" label="A+" :span="4">
<image-link :url="model.aplus" />
</n-descriptions-item>
</n-descriptions> </n-descriptions>
</div> </div>
</template> </template>

View File

@ -82,20 +82,38 @@ defineExpose({
<template> <template>
<div class="ids-input"> <div class="ids-input">
<n-space> <div class="header">
<n-button @click="fileDialog.open()" :disabled="disabled" round size="small"> <span>
<template #icon> <n-button @click="fileDialog.open()" :disabled="disabled" round size="small">
<gg-import /> <template #icon>
</template> <n-icon>
导入 <gg-import />
</n-button> </n-icon>
<n-button :disabled="disabled" @click="handleExportIds" round size="small"> </template>
<template #icon> 导入
<ion-arrow-up-right-box-outline /> </n-button>
</template> <n-button :disabled="disabled" @click="handleExportIds" round size="small">
导出 <template #icon>
</n-button> <n-icon>
</n-space> <ion-arrow-up-right-box-outline />
</n-icon>
</template>
导出
</n-button>
</span>
<span>
<n-popover v-if="$slots['extra-settings']" placement="bottom-end" trigger="click">
<template #trigger>
<n-button :disabled="disabled" circle size="small">
<template #icon>
<n-icon size="18px"><solar-settings-linear /></n-icon>
</template>
</n-button>
</template>
<slot name="extra-settings" />
</n-popover>
</span>
</div>
<div style="height: 7px" /> <div style="height: 7px" />
<n-form-item ref="detail-form-item" label-placement="left" :rule="formItemRule"> <n-form-item ref="detail-form-item" label-placement="left" :rule="formItemRule">
<n-input <n-input
@ -109,10 +127,22 @@ defineExpose({
</div> </div>
</template> </template>
<style lang="scss"> <style scoped lang="scss">
.asin-input { .asin-input {
width: 100%; width: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
.header {
display: flex;
flex-direction: row;
justify-content: space-between;
> *:first-of-type {
display: flex;
flex-direction: row;
gap: 5px;
}
}
</style> </style>

View File

@ -35,7 +35,7 @@ defineProps<{
</n-card> </n-card>
</template> </template>
<style> <style scoped lang="scss">
.progress-report { .progress-report {
width: 100%; width: 100%;
height: 100%; height: 100%;

View File

@ -1,6 +1,4 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { AmazonReview } from '~/logic/page-worker/types';
defineProps<{ defineProps<{
model: AmazonReview; model: AmazonReview;
}>(); }>();

View File

@ -1,7 +1,6 @@
<script lang="ts" setup> <script lang="ts" setup>
import { useElementSize } from '@vueuse/core'; import { useElementSize } from '@vueuse/core';
import { exportToXLSX, Header, importFromXLSX } from '~/logic/data-io'; import { exportToXLSX, Header, importFromXLSX } from '~/logic/excel';
import type { AmazonReview } from '~/logic/page-worker/types';
import { reviewItems } from '~/logic/storages/amazon'; import { reviewItems } from '~/logic/storages/amazon';
const props = defineProps<{ asin: string }>(); const props = defineProps<{ asin: string }>();

64
src/global.d.ts vendored
View File

@ -8,3 +8,67 @@ declare module '*.vue' {
const component: any; const component: any;
export default component; export default component;
} }
declare type AmazonSearchItem = {
keywords: string;
page: number;
link: string;
title: string;
asin: string;
rank: number;
price?: string;
imageSrc: string;
createTime: string;
};
declare type AmazonDetailItem = {
asin: string;
title: string;
price?: string;
rating?: number;
ratingCount?: number;
category1?: { name: string; rank: number };
category2?: { name: string; rank: number };
imageUrls?: string[];
aplus?: string;
topReviews?: AmazonReview[];
};
declare type AmazonReview = {
id: string;
username: string;
title: string;
rating: string;
dateInfo: string;
content: string;
imageSrc: string[];
};
declare type AmazonItem = Pick<AmazonSearchItem, 'asin'> &
Partial<AmazonSearchItem> &
Partial<AmazonDetailItem> & { hasDetail: boolean };
declare type HomedepotDetailItem = {
OSMID: string;
link: string;
brandName?: string;
title: string;
price: string;
rate?: string;
reviewCount?: number;
mainImageUrl: string;
modelInfo?: string;
};
declare type LowesDetailItem = {
OSMID: string;
link: string;
brandName?: string;
title: string;
price: string;
rate?: string;
innerText: string;
reviewCount?: number;
mainImageUrl: string;
modelInfo?: string;
};

View File

@ -9,7 +9,7 @@ class Worksheet {
this.workbook = wb; this.workbook = wb;
} }
async readJson(data: Record<string, unknown>[], options: { headers?: Header[] } = {}) { async readJson(data: Record<string, unknown>[], options: { headers?: Header<any>[] } = {}) {
const { const {
headers = data.length > 0 headers = data.length > 0
? Object.keys(data[0]).map((k) => ({ label: k, prop: k }) as Header) ? Object.keys(data[0]).map((k) => ({ label: k, prop: k }) as Header)
@ -22,9 +22,9 @@ class Worksheet {
const cols = headers.filter((h) => h.ignore?.out !== true); const cols = headers.filter((h) => h.ignore?.out !== true);
for (let j = 0; j < cols.length; j++) { for (let j = 0; j < cols.length; j++) {
const header = cols[j]; const header = cols[j];
const value = getAttribute(item, header.prop); const value = getAttribute(item, header.prop as string);
if (header.formatOutputValue) { if (header.formatOutputValue) {
record[header.label] = await header.formatOutputValue(value, i); record[header.label] = await header.formatOutputValue(value, i, item);
} else if (['string', 'number', 'bigint', 'boolean'].includes(typeof value)) { } else if (['string', 'number', 'bigint', 'boolean'].includes(typeof value)) {
record[header.label] = value; record[header.label] = value;
} else { } else {
@ -76,7 +76,7 @@ class Worksheet {
const value = header.parseImportValue const value = header.parseImportValue
? await header.parseImportValue(item[header.label], i) ? await header.parseImportValue(item[header.label], i)
: item[header.label]; : item[header.label];
setAttribute(mappedItem, header.prop, value); setAttribute(mappedItem, header.prop as string, value);
} }
return mappedItem; return mappedItem;
}), }),
@ -172,11 +172,11 @@ function setAttribute(obj: Record<string, unknown>, path: string, value: unknown
current[finalKey] = value; current[finalKey] = value;
} }
export type Header = { export type Header<T = any> = {
label: string; label: string;
prop: string; prop: keyof T | string;
parseImportValue?: (val: any, index: number) => any; parseImportValue?: (val: any, index: number) => any;
formatOutputValue?: (val: any, index: number) => any; formatOutputValue?: (val: any, index: number, rowData: T) => any;
ignore?: { ignore?: {
in?: boolean; in?: boolean;
out?: boolean; out?: boolean;
@ -193,26 +193,26 @@ export type ImportBaseOptions = {
headers?: Header[]; headers?: Header[];
}; };
export function castRecordsByHeaders<T = Record<string, unknown>>( export function castRecordsByHeaders(
jsonData: Record<string, unknown>[], jsonData: Record<string, unknown>[],
headers: Omit<Header, 'parseImportValue'>[], headers: Omit<Header, 'parseImportValue'>[],
): Promise<T[]> { ): Promise<Record<string, unknown>[]> {
return Promise.all( return Promise.all(
jsonData.map(async (item, i) => { jsonData.map(async (item, i) => {
const record: Record<string, unknown> = {}; const record: Record<string, unknown> = {};
const cols = headers.filter((h) => h.ignore?.out !== true); const cols = headers.filter((h) => h.ignore?.out !== true);
for (let j = 0; j < cols.length; j++) { for (let j = 0; j < cols.length; j++) {
const header = cols[j]; const header = cols[j];
const value = getAttribute(item, header.prop); const value = getAttribute(item, header.prop as string);
if (header.formatOutputValue) { if (header.formatOutputValue) {
record[header.label] = await header.formatOutputValue(value, i); record[header.label] = await header.formatOutputValue(value, i, item);
} else if (['string', 'number', 'bigint', 'boolean'].includes(typeof value)) { } else if (['string', 'number', 'bigint', 'boolean'].includes(typeof value)) {
record[header.label] = value; record[header.label] = value;
} else { } else {
record[header.label] = JSON.stringify(value); record[header.label] = JSON.stringify(value);
} }
} }
return record as T; return record;
}), }),
); );
} }

View File

@ -1,5 +1,5 @@
import Emittery from 'emittery'; import Emittery from 'emittery';
import type { AmazonDetailItem, AmazonPageWorker, AmazonPageWorkerEvents } from './types'; import type { AmazonPageWorker, AmazonPageWorkerEvents, LanchTaskBaseOptions } from './types';
import type { Tabs } from 'webextension-polyfill'; import type { Tabs } from 'webextension-polyfill';
import { withErrorHandling } from '../error-handler'; import { withErrorHandling } from '../error-handler';
import { import {
@ -22,11 +22,11 @@ class AmazonPageWorkerImpl implements AmazonPageWorker {
} }
return this._instance; return this._instance;
} }
private constructor() {}
//#endregion //#endregion
private constructor() {}
private readonly _controlChannel = new Emittery<{ interrupt: undefined }>(); private readonly _controlChannel = new Emittery<{ interrupt: undefined }>();
public readonly channel = new Emittery<AmazonPageWorkerEvents>(); public readonly channel = new Emittery<AmazonPageWorkerEvents>();
private async getCurrentTab(): Promise<Tabs.Tab> { private async getCurrentTab(): Promise<Tabs.Tab> {
@ -103,7 +103,7 @@ class AmazonPageWorkerImpl implements AmazonPageWorker {
} }
@withErrorHandling @withErrorHandling
public async wanderDetailPage(entry: string) { public async wanderDetailPage(entry: string, aplus: boolean = false) {
//#region Initial Meta Info //#region Initial Meta Info
const params = { asin: '', url: '' }; const params = { asin: '', url: '' };
if (entry.match(/^https?:\/\/www\.amazon\.com.*\/dp\/[A-Z0-9]{10}/)) { if (entry.match(/^https?:\/\/www\.amazon\.com.*\/dp\/[A-Z0-9]{10}/)) {
@ -182,7 +182,10 @@ class AmazonPageWorkerImpl implements AmazonPageWorker {
// }); // });
//#endregion //#endregion
// #region Get APlus Sreen shot // #region Get APlus Sreen shot
await injector.scanAPlus(); if (aplus && (await injector.scanAPlus())) {
const { b64: base64data } = await injector.captureAPlus();
this.channel.emit('item-aplus-screenshot-collect', { asin: params.asin, base64data });
}
// #endregion // #endregion
} }
@ -212,8 +215,9 @@ class AmazonPageWorkerImpl implements AmazonPageWorker {
public async runSearchPageTask( public async runSearchPageTask(
keywordsList: string[], keywordsList: string[],
progress?: (remains: string[]) => Promise<void>, options: LanchTaskBaseOptions = {},
): Promise<void> { ): Promise<void> {
const { progress } = options;
let remains = [...keywordsList]; let remains = [...keywordsList];
let interrupt = false; let interrupt = false;
const unsubscribe = this._controlChannel.on('interrupt', () => { const unsubscribe = this._controlChannel.on('interrupt', () => {
@ -230,8 +234,9 @@ class AmazonPageWorkerImpl implements AmazonPageWorker {
public async runDetaiPageTask( public async runDetaiPageTask(
asins: string[], asins: string[],
progress?: (remains: string[]) => Promise<void>, options: LanchTaskBaseOptions & { aplus?: boolean } = {},
): Promise<void> { ): Promise<void> {
const { progress, aplus = false } = options;
const remains = [...asins]; const remains = [...asins];
let interrupt = false; let interrupt = false;
const unsubscribe = this._controlChannel.on('interrupt', () => { const unsubscribe = this._controlChannel.on('interrupt', () => {
@ -239,7 +244,7 @@ class AmazonPageWorkerImpl implements AmazonPageWorker {
}); });
while (remains.length > 0 && !interrupt) { while (remains.length > 0 && !interrupt) {
const asin = remains.shift()!; const asin = remains.shift()!;
await this.wanderDetailPage(asin); await this.wanderDetailPage(asin, aplus);
progress && progress(remains); progress && progress(remains);
} }
unsubscribe(); unsubscribe();
@ -247,8 +252,9 @@ class AmazonPageWorkerImpl implements AmazonPageWorker {
public async runReviewPageTask( public async runReviewPageTask(
asins: string[], asins: string[],
progress?: (remains: string[]) => Promise<void>, options: LanchTaskBaseOptions = {},
): Promise<void> { ): Promise<void> {
const { progress } = options;
const remains = [...asins]; const remains = [...asins];
let interrupt = false; let interrupt = false;
const unsubscribe = this._controlChannel.on('interrupt', () => { const unsubscribe = this._controlChannel.on('interrupt', () => {
@ -268,7 +274,7 @@ class AmazonPageWorkerImpl implements AmazonPageWorker {
} }
export default { export default {
useAmazonPageWorker(): AmazonPageWorker { getAmazonPageWorker(): AmazonPageWorker {
return AmazonPageWorkerImpl.getInstance(); return AmazonPageWorkerImpl.getInstance();
}, },
}; };

View File

@ -1,5 +1,5 @@
import Emittery from 'emittery'; import Emittery from 'emittery';
import { HomedepotEvents, HomedepotWorker } from './types'; import type { HomedepotEvents, HomedepotWorker, LanchTaskBaseOptions } from './types';
import { Tabs } from 'webextension-polyfill'; import { Tabs } from 'webextension-polyfill';
import { withErrorHandling } from '../error-handler'; import { withErrorHandling } from '../error-handler';
import { HomedepotDetailPageInjector } from '~/logic/web-injectors/homedepot'; import { HomedepotDetailPageInjector } from '~/logic/web-injectors/homedepot';
@ -12,7 +12,7 @@ class HomedepotWorkerImpl implements HomedepotWorker {
} }
return HomedepotWorkerImpl._instance as HomedepotWorker; return HomedepotWorkerImpl._instance as HomedepotWorker;
} }
private constructor() {} protected constructor() {}
readonly channel: Emittery<HomedepotEvents> = new Emittery(); readonly channel: Emittery<HomedepotEvents> = new Emittery();
@ -36,10 +36,8 @@ class HomedepotWorkerImpl implements HomedepotWorker {
}, 1000); }, 1000);
} }
async runDetailPageTask( async runDetailPageTask(OSMIDs: string[], options: LanchTaskBaseOptions = {}): Promise<void> {
OSMIDs: string[], const { progress } = options;
progress?: (remains: string[]) => Promise<void> | void,
): Promise<void> {
const remains = [...OSMIDs]; const remains = [...OSMIDs];
let interrupt = false; let interrupt = false;
const unsubscribe = this._controlChannel.on('interrupt', () => { const unsubscribe = this._controlChannel.on('interrupt', () => {

View File

@ -1,45 +1,8 @@
import type Emittery from 'emittery'; import type Emittery from 'emittery';
import { TaskQueue } from '../task-queue';
type AmazonSearchItem = { export type LanchTaskBaseOptions = { progress?: (remains: string[]) => Promise<void> | void };
keywords: string;
page: number;
link: string;
title: string;
asin: string;
rank: number;
price?: string;
imageSrc: string;
createTime: string;
};
type AmazonDetailItem = { export interface AmazonPageWorkerEvents {
asin: string;
title: string;
price?: string;
rating?: number;
ratingCount?: number;
category1?: { name: string; rank: number };
category2?: { name: string; rank: number };
imageUrls?: string[];
topReviews?: AmazonReview[];
};
type AmazonReview = {
id: string;
username: string;
title: string;
rating: string;
dateInfo: string;
content: string;
imageSrc: string[];
};
type AmazonItem = Pick<AmazonSearchItem, 'asin'> &
Partial<AmazonSearchItem> &
Partial<AmazonDetailItem> & { hasDetail: boolean };
interface AmazonPageWorkerEvents {
/** /**
* The event is fired when worker collected links to items on the Amazon search page. * The event is fired when worker collected links to items on the Amazon search page.
*/ */
@ -63,6 +26,10 @@ interface AmazonPageWorkerEvents {
* The event is fired when top reviews collected in detail page * The event is fired when top reviews collected in detail page
*/ */
['item-top-reviews-collected']: Pick<AmazonDetailItem, 'asin' | 'topReviews'>; ['item-top-reviews-collected']: Pick<AmazonDetailItem, 'asin' | 'topReviews'>;
/**
* The event is fired when aplus screenshot-collect
*/
['item-aplus-screenshot-collect']: { asin: string; base64data: string };
/** /**
* The event is fired when reviews collected in all review page * The event is fired when reviews collected in all review page
*/ */
@ -73,7 +40,7 @@ interface AmazonPageWorkerEvents {
['error']: { message: string; url?: string }; ['error']: { message: string; url?: string };
} }
interface AmazonPageWorker { export interface AmazonPageWorker {
/** /**
* The channel for communication with the Amazon page worker. * The channel for communication with the Amazon page worker.
* This is an instance of Emittery, which allows for event-based communication. * This is an instance of Emittery, which allows for event-based communication.
@ -83,29 +50,26 @@ interface AmazonPageWorker {
/** /**
* Browsing goods search page and collect links to those goods. * Browsing goods search page and collect links to those goods.
* @param keywordsList - The keywords list to search for on Amazon. * @param keywordsList - The keywords list to search for on Amazon.
* @param progress The callback that receive remaining keywords as the parameter. * @param options The Options Specify Behaviors.
*/ */
runSearchPageTask( runSearchPageTask(keywordsList: string[], options?: LanchTaskBaseOptions): Promise<void>;
keywordsList: string[],
progress?: (remains: string[]) => Promise<void>,
): Promise<void>;
/** /**
* Browsing goods detail page and collect target information. * Browsing goods detail page and collect target information.
* @param asins Amazon Standard Identification Numbers. * @param asins Amazon Standard Identification Numbers.
* @param progress The callback that receive remaining asins as the parameter. * @param options The Options Specify Behaviors.
*/ */
runDetaiPageTask(asins: string[], progress?: (remains: string[]) => Promise<void>): Promise<void>; runDetaiPageTask(
asins: string[],
options?: LanchTaskBaseOptions & { aplus?: boolean },
): Promise<void>;
/** /**
* Browsing goods review page and collect target information. * Browsing goods review page and collect target information.
* @param asins Amazon Standard Identification Numbers. * @param asins Amazon Standard Identification Numbers.
* @param progress The callback that receive remaining asins as the parameter. * @param options The Options Specify Behaviors.
*/ */
runReviewPageTask( runReviewPageTask(asins: string[], options?: LanchTaskBaseOptions): Promise<void>;
asins: string[],
progress?: (remains: string[]) => Promise<void>,
): Promise<void>;
/** /**
* Stop the worker. * Stop the worker.
@ -113,19 +77,7 @@ interface AmazonPageWorker {
stop(): Promise<void>; stop(): Promise<void>;
} }
type HomedepotDetailItem = { export interface HomedepotEvents {
OSMID: string;
link: string;
brandName?: string;
title: string;
price: string;
rate?: string;
reviewCount?: number;
mainImageUrl: string;
modelInfo?: string;
};
interface HomedepotEvents {
/** /**
* The event is fired when detail items collect * The event is fired when detail items collect
*/ */
@ -136,7 +88,7 @@ interface HomedepotEvents {
['error']: { message: string; url?: string }; ['error']: { message: string; url?: string };
} }
interface HomedepotWorker { export interface HomedepotWorker {
/** /**
* The channel for communication with the Homedepot page worker. * The channel for communication with the Homedepot page worker.
*/ */
@ -145,10 +97,7 @@ interface HomedepotWorker {
/** /**
* Browsing goods detail page and collect target information * Browsing goods detail page and collect target information
*/ */
runDetailPageTask( runDetailPageTask(OSMIDs: string[], options?: LanchTaskBaseOptions): Promise<void>;
OSMIDs: string[],
progress?: (remains: string[]) => Promise<void> | void,
): Promise<void>;
/** /**
* Stop the worker. * Stop the worker.
@ -156,20 +105,7 @@ interface HomedepotWorker {
stop(): Promise<void>; stop(): Promise<void>;
} }
type LowesDetailItem = { export interface LowesEvents {
OSMID: string;
link: string;
brandName?: string;
title: string;
price: string;
rate?: string;
innerText: string;
reviewCount?: number;
mainImageUrl: string;
modelInfo?: string;
};
interface LowesEvents {
/** /**
* The event is fired when detail items collect * The event is fired when detail items collect
*/ */
@ -180,7 +116,7 @@ interface LowesEvents {
['error']: { message: string; url?: string }; ['error']: { message: string; url?: string };
} }
interface LowesWorker { export interface LowesWorker {
/** /**
* The channel for communication with the Lowes page worker. * The channel for communication with the Lowes page worker.
*/ */
@ -189,10 +125,7 @@ interface LowesWorker {
/** /**
* Browsing goods detail page and collect target information * Browsing goods detail page and collect target information
*/ */
runDetailPageTask( runDetailPageTask(urls: string[], options?: LanchTaskBaseOptions): Promise<void>;
urls: string[],
progress?: (remains: string[]) => Promise<void> | void,
): Promise<void>;
/** /**
* Stop the worker. * Stop the worker.

View File

@ -1,10 +1,4 @@
import { useWebExtensionStorage } from '~/composables/useWebExtensionStorage'; import { useWebExtensionStorage } from '~/composables/useWebExtensionStorage';
import type {
AmazonDetailItem,
AmazonItem,
AmazonReview,
AmazonSearchItem,
} from '~/logic/page-worker/types';
export const keywordsList = useWebExtensionStorage<string[]>('keywordsList', ['']); export const keywordsList = useWebExtensionStorage<string[]>('keywordsList', ['']);
@ -14,6 +8,11 @@ export const reviewAsinInput = useWebExtensionStorage<string>('reviewAsinInputTe
export const searchItems = useWebExtensionStorage<AmazonSearchItem[]>('searchItems', []); export const searchItems = useWebExtensionStorage<AmazonSearchItem[]>('searchItems', []);
export const detailWorkerSettings = useWebExtensionStorage<{ aplus: boolean }>(
'amazon-detail-worker-settings',
{ aplus: false },
);
export const detailItems = useWebExtensionStorage<Map<string, AmazonDetailItem>>( export const detailItems = useWebExtensionStorage<Map<string, AmazonDetailItem>>(
'detailItems', 'detailItems',
new Map(), new Map(),

View File

@ -1,6 +1,4 @@
import { useWebExtensionStorage } from '~/composables/useWebExtensionStorage'; import { useWebExtensionStorage } from '~/composables/useWebExtensionStorage';
import { HomedepotDetailItem } from '../page-worker/types';
export const detailItems = useWebExtensionStorage<Map<string, HomedepotDetailItem>>( export const detailItems = useWebExtensionStorage<Map<string, HomedepotDetailItem>>(
'homedepot-details', 'homedepot-details',
new Map(), new Map(),

31
src/logic/upload.ts Normal file
View File

@ -0,0 +1,31 @@
import { remoteHost } from '~/env';
export async function uploadImage(
base64String: string,
filename: string,
): Promise<string | undefined> {
// Remove the data URL prefix if present
const base64Data = base64String.replace(/^data:image\/png;base64,/, '');
// Convert base64 to binary
const byteCharacters = atob(base64Data);
const byteNumbers = new Array(byteCharacters.length);
for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i);
}
const byteArray = new Uint8Array(byteNumbers);
// Create a Blob from the byte array
const blob = new Blob([byteArray], { type: 'image/png' });
// Create FormData and append the file
const formData = new FormData();
formData.append('file', blob, filename);
const url = `http://${remoteHost}/upload/image/${encodeURIComponent(filename)}`;
return fetch(url, {
method: 'POST',
body: formData,
})
.then((response) => response.json())
.then((data) => {
return data.file ? `http://${remoteHost}${data.file}` : undefined;
});
}

View File

@ -1,5 +1,4 @@
import { BaseInjector } from './base'; import { BaseInjector } from './base';
import { AmazonReview, AmazonSearchItem } from '../page-worker/types';
export class AmazonSearchPageInjector extends BaseInjector { export class AmazonSearchPageInjector extends BaseInjector {
public waitForPageLoaded() { public waitForPageLoaded() {
@ -242,6 +241,7 @@ export class AmazonDetailPageInjector extends BaseInjector {
/(?<="large":")https:\/\/m.media-amazon.com\/images\/I\/[\w\d\.\-+]+(?=")/g, /(?<="large":")https:\/\/m.media-amazon.com\/images\/I\/[\w\d\.\-+]+(?=")/g,
); );
} }
document.querySelector<HTMLElement>('header > [data-action="a-popover-close"]')?.click();
return urls; return urls;
}); });
} }
@ -302,7 +302,10 @@ export class AmazonDetailPageInjector extends BaseInjector {
public async scanAPlus() { public async scanAPlus() {
return this.run(async () => { return this.run(async () => {
const aplusEl = document.querySelector<HTMLElement>('#aplus')!; const aplusEl = document.querySelector<HTMLElement>('#aplus_feature_div');
if (!aplusEl) {
return false;
}
while (aplusEl.getClientRects().length === 0) { while (aplusEl.getClientRects().length === 0) {
await new Promise((resolve) => setTimeout(resolve, 500)); await new Promise((resolve) => setTimeout(resolve, 500));
} }
@ -315,8 +318,13 @@ export class AmazonDetailPageInjector extends BaseInjector {
window.scrollBy({ top: 100, behavior: 'smooth' }); window.scrollBy({ top: 100, behavior: 'smooth' });
await new Promise((resolve) => setTimeout(resolve, 100 + ~~(100 * Math.random()))); await new Promise((resolve) => setTimeout(resolve, 100 + ~~(100 * Math.random())));
} }
return true;
}); });
} }
public async captureAPlus() {
return this.screenshot({ type: 'CSS', selector: '#aplus_feature_div' });
}
} }
export class AmazonReviewPageInjector extends BaseInjector { export class AmazonReviewPageInjector extends BaseInjector {

View File

@ -1,17 +1,42 @@
import { Tabs } from 'webextension-polyfill'; import { Tabs } from 'webextension-polyfill';
import { exec } from '~/logic/execute-script'; import { exec } from '~/logic/execute-script';
import type { ProtocolMap } from 'webext-bridge';
export class BaseInjector { export class BaseInjector {
readonly _tab: Tabs.Tab; readonly _tab: Tabs.Tab;
readonly _timeout: number; readonly _timeout: number;
readonly _appContext: AppContext;
constructor(tab: Tabs.Tab, timeout: number = 30000) { constructor(tab: Tabs.Tab, options: { timeout?: number; appContext?: AppContext } = {}) {
const { timeout = 30000, appContext = 'sidepanel' } = options;
this._tab = tab; this._tab = tab;
this._timeout = timeout; this._timeout = timeout;
this._appContext = appContext;
} }
run<T, P extends Record<string, unknown>>( protected async getMessageSender() {
let sender = null;
switch (this._appContext) {
case 'sidepanel':
sender = await import('webext-bridge/sidepanel');
return { sendMessage: sender.sendMessage };
case 'options':
sender = await import('webext-bridge/options');
return { sendMessage: sender.sendMessage };
}
}
protected async screenshot(params: ProtocolMap['html-to-image']['data']) {
const sender = await this.getMessageSender();
return Promise.resolve<ProtocolMap['html-to-image']['return']>(
sender.sendMessage('html-to-image', params, {
context: 'content-script',
tabId: this._tab.id!,
}),
);
}
protected run<T, P extends Record<string, unknown>>(
func: (payload: P) => Promise<T>, func: (payload: P) => Promise<T>,
payload?: P, payload?: P,
): Promise<T> { ): Promise<T> {

View File

@ -1,10 +1,9 @@
import { Tabs } from 'webextension-polyfill'; import { Tabs } from 'webextension-polyfill';
import { BaseInjector } from './base'; import { BaseInjector } from './base';
import { HomedepotDetailItem } from '../page-worker/types';
export class HomedepotDetailPageInjector extends BaseInjector { export class HomedepotDetailPageInjector extends BaseInjector {
constructor(tab: Tabs.Tab) { constructor(tab: Tabs.Tab) {
super(tab, 60000); super(tab, { timeout: 60000 });
} }
public waitForPageLoad() { public waitForPageLoad() {

View File

@ -3,7 +3,7 @@ import { BaseInjector } from './base';
export class LowesDetailPageInjector extends BaseInjector { export class LowesDetailPageInjector extends BaseInjector {
constructor(tab: Tabs.Tab) { constructor(tab: Tabs.Tab) {
super(tab, 60000); super(tab, { timeout: 60000 });
} }
public waitForPageLoad() { public waitForPageLoad() {

View File

@ -2,8 +2,7 @@
import { NButton, NSpace } from 'naive-ui'; import { NButton, NSpace } from 'naive-ui';
import type { TableColumn } from '~/components/ResultTable.vue'; import type { TableColumn } from '~/components/ResultTable.vue';
import { useCloudExporter } from '~/composables/useCloudExporter'; import { useCloudExporter } from '~/composables/useCloudExporter';
import { castRecordsByHeaders, createWorkbook, Header, importFromXLSX } from '~/logic/data-io'; import { castRecordsByHeaders, createWorkbook, Header, importFromXLSX } from '~/logic/excel';
import type { AmazonItem, AmazonReview } from '~/logic/page-worker/types';
import { allItems, reviewItems } from '~/logic/storages/amazon'; import { allItems, reviewItems } from '~/logic/storages/amazon';
const message = useMessage(); const message = useMessage();
@ -47,7 +46,7 @@ const columns: TableColumn[] = [
type: 'expand', type: 'expand',
expandable: (row) => row.hasDetail, expandable: (row) => row.hasDetail,
renderExpand(row) { renderExpand(row) {
return <detail-description model={row} />; return <amazon-detail-description model={row} />;
}, },
}, },
{ {
@ -133,7 +132,7 @@ const columns: TableColumn[] = [
}, },
]; ];
const extraHeaders: Header[] = [ const extraHeaders: Header<AmazonItem>[] = [
{ prop: 'link', label: '商品链接' }, { prop: 'link', label: '商品链接' },
{ {
prop: 'hasDetail', prop: 'hasDetail',
@ -150,12 +149,15 @@ const extraHeaders: Header[] = [
{ {
prop: 'imageUrls', prop: 'imageUrls',
label: '商品图片链接', label: '商品图片链接',
formatOutputValue: (val?: string[]) => val?.join(';'), formatOutputValue: (val: string[] | undefined, _i, rowData) => {
if (!val) return undefined;
return rowData.aplus ? val.concat([rowData.aplus]).join(';') : val.join(';');
},
parseImportValue: (val?: string) => val?.split(';'), parseImportValue: (val?: string) => val?.split(';'),
}, },
]; ];
const reviewHeaders: Header[] = [ const reviewHeaders: Header<AmazonReview>[] = [
{ prop: 'asin', label: 'ASIN' }, { prop: 'asin', label: 'ASIN' },
{ prop: 'username', label: '用户名' }, { prop: 'username', label: '用户名' },
{ prop: 'title', label: '标题' }, { prop: 'title', label: '标题' },

View File

@ -1,7 +1,7 @@
<script setup lang="tsx"> <script setup lang="tsx">
import type { TableColumn } from '~/components/ResultTable.vue'; import type { TableColumn } from '~/components/ResultTable.vue';
import { useCloudExporter } from '~/composables/useCloudExporter'; import { useCloudExporter } from '~/composables/useCloudExporter';
import { castRecordsByHeaders, exportToXLSX, Header, importFromXLSX } from '~/logic/data-io'; import { castRecordsByHeaders, exportToXLSX, Header, importFromXLSX } from '~/logic/excel';
import { allItems } from '~/logic/storages/homedepot'; import { allItems } from '~/logic/storages/homedepot';
const message = useMessage(); const message = useMessage();

View File

@ -29,7 +29,6 @@ watch(currentUrl, (newVal) => {
site.value = 'homedepot'; site.value = 'homedepot';
break; break;
default: default:
router.push(`/${site.value}`);
break; break;
} }
} }

View File

@ -2,8 +2,8 @@
import type { Timeline } from '~/components/ProgressReport.vue'; import type { Timeline } from '~/components/ProgressReport.vue';
import { useLongTask } from '~/composables/useLongTask'; import { useLongTask } from '~/composables/useLongTask';
import { amazon as pageWorker } from '~/logic/page-worker'; import { amazon as pageWorker } from '~/logic/page-worker';
import { AmazonDetailItem } from '~/logic/page-worker/types'; import { detailAsinInput, detailItems, detailWorkerSettings } from '~/logic/storages/amazon';
import { detailAsinInput, detailItems } from '~/logic/storages/amazon'; import { uploadImage } from '~/logic/upload';
const message = useMessage(); const message = useMessage();
@ -23,7 +23,7 @@ watch(isRunning, (newVal) => {
const asinInputRef = useTemplateRef('asin-input'); const asinInputRef = useTemplateRef('asin-input');
//#region Page Worker Code //#region Page Worker Code
const worker = pageWorker.useAmazonPageWorker(); // Page Worker const worker = pageWorker.getAmazonPageWorker(); // Page Worker
worker.channel.on('error', ({ message: msg }) => { worker.channel.on('error', ({ message: msg }) => {
timelines.value.push({ timelines.value.push({
type: 'error', type: 'error',
@ -73,6 +73,18 @@ worker.channel.on('item-top-reviews-collected', (ev) => {
}); });
updateDetailItems(ev); updateDetailItems(ev);
}); });
worker.channel.on('item-aplus-screenshot-collect', (ev) => {
timelines.value.push({
type: 'success',
title: `商品${ev.asin}A+`,
time: new Date().toLocaleString(),
content: `获取到A+`,
});
uploadImage(ev.base64data, `${ev.asin}.png`).then((url) => {
url && updateDetailItems({ asin: ev.asin, aplus: url });
});
});
const updateDetailItems = (row: { asin: string } & Partial<AmazonDetailItem>) => { const updateDetailItems = (row: { asin: string } & Partial<AmazonDetailItem>) => {
const asin = row.asin; const asin = row.asin;
if (detailItems.value.has(row.asin)) { if (detailItems.value.has(row.asin)) {
@ -94,8 +106,11 @@ const task = async () => {
content: '开始数据采集', content: '开始数据采集',
}, },
]; ];
await worker.runDetaiPageTask(asinList, async (remains) => { await worker.runDetaiPageTask(asinList, {
detailAsinInput.value = remains.join('\n'); progress: (remains) => {
detailAsinInput.value = remains.join('\n');
},
aplus: detailWorkerSettings.value.aplus,
}); });
timelines.value.push({ timelines.value.push({
type: 'info', type: 'info',
@ -120,7 +135,17 @@ const handleInterrupt = () => {
<div class="detail-page-entry"> <div class="detail-page-entry">
<header-title>Amazon Detail</header-title> <header-title>Amazon Detail</header-title>
<div class="interative-section"> <div class="interative-section">
<ids-input v-model="detailAsinInput" :disabled="isRunning" ref="asin-input" /> <ids-input v-model="detailAsinInput" :disabled="isRunning" ref="asin-input">
<template #extra-settings>
<div class="setting-panel">
<n-form label-placement="left">
<n-form-item label="Aplus: " :feedback-style="{ display: 'none' }">
<n-switch v-model:value="detailWorkerSettings.aplus" />
</n-form-item>
</n-form>
</div>
</template>
</ids-input>
<n-button v-if="!isRunning" round size="large" type="primary" @click="handleStart"> <n-button v-if="!isRunning" round size="large" type="primary" @click="handleStart">
<template #icon> <template #icon>
<ant-design-thunderbolt-outlined /> <ant-design-thunderbolt-outlined />
@ -173,4 +198,8 @@ const handleInterrupt = () => {
margin-top: 10px; margin-top: 10px;
width: 95%; width: 95%;
} }
.setting-panel {
padding: 7px 10px;
}
</style> </style>

View File

@ -2,7 +2,6 @@
import type { Timeline } from '~/components/ProgressReport.vue'; import type { Timeline } from '~/components/ProgressReport.vue';
import { useLongTask } from '~/composables/useLongTask'; import { useLongTask } from '~/composables/useLongTask';
import { amazon as pageWorker } from '~/logic/page-worker'; import { amazon as pageWorker } from '~/logic/page-worker';
import type { AmazonReview } from '~/logic/page-worker/types';
import { reviewAsinInput, reviewItems } from '~/logic/storages/amazon'; import { reviewAsinInput, reviewItems } from '~/logic/storages/amazon';
const { isRunning, startTask } = useLongTask(); const { isRunning, startTask } = useLongTask();
@ -16,7 +15,7 @@ watch(isRunning, (newVal) => {
newVal ? emit('start') : emit('stop'); newVal ? emit('start') : emit('stop');
}); });
const worker = pageWorker.useAmazonPageWorker(); const worker = pageWorker.getAmazonPageWorker();
worker.channel.on('error', ({ message: msg }) => { worker.channel.on('error', ({ message: msg }) => {
timelines.value.push({ timelines.value.push({
type: 'error', type: 'error',
@ -52,8 +51,10 @@ const task = async () => {
content: '开始数据采集', content: '开始数据采集',
}, },
]; ];
await worker.runReviewPageTask(asinList, async (remains) => { await worker.runReviewPageTask(asinList, {
reviewAsinInput.value = remains.join('\n'); progress: (remains) => {
reviewAsinInput.value = remains.join('\n');
},
}); });
timelines.value.push({ timelines.value.push({
type: 'info', type: 'info',

View File

@ -19,7 +19,7 @@ watch(isRunning, (newVal) => {
}); });
//#region Initial Page Worker //#region Initial Page Worker
const worker = pageWorker.useAmazonPageWorker(); const worker = pageWorker.getAmazonPageWorker();
worker.channel.on('error', ({ message: msg }) => { worker.channel.on('error', ({ message: msg }) => {
timelines.value.push({ timelines.value.push({
type: 'error', type: 'error',
@ -54,16 +54,18 @@ const task = async () => {
}, },
]; ];
timelines.value.push(); timelines.value.push();
await worker.runSearchPageTask(kws, async (remains) => { await worker.runSearchPageTask(kws, {
if (remains.length > 0) { progress: (remains) => {
timelines.value.push({ if (remains.length > 0) {
type: 'info', timelines.value.push({
title: '开始', type: 'info',
time: new Date().toLocaleString(), title: '开始',
content: `关键词: ${remains[0]} 数据采集开始`, time: new Date().toLocaleString(),
}); content: `关键词: ${remains[0]} 数据采集开始`,
keywordsList.value = remains; });
} keywordsList.value = remains;
}
},
}); });
timelines.value.push({ timelines.value.push({
type: 'info', type: 'info',

View File

@ -28,7 +28,14 @@ const handleStart = () =>
content: '任务开始', content: '任务开始',
time: new Date().toLocaleString(), time: new Date().toLocaleString(),
}); });
await worker.runDetailPageTask(inputText.value.split('\n').filter((id) => /\d+/.exec(id))); await worker.runDetailPageTask(
inputText.value.split('\n').filter((id) => /\d+/.exec(id)),
{
progress: (remains) => {
inputText.value = remains.join('\n');
},
},
);
timelines.value.push({ timelines.value.push({
type: 'info', type: 'info',
title: `结束`, title: `结束`,