diff --git a/scripts/prepare.ts b/scripts/prepare.ts index c547d04..3bc062b 100644 --- a/scripts/prepare.ts +++ b/scripts/prepare.ts @@ -8,7 +8,7 @@ import { isDev, log, port, r } from './utils'; * Stub index.html to use Vite in development */ async function stubIndexHtml() { - const views = ['sidepanel']; + const views = ['sidepanel', 'options']; for (const view of views) { await fs.ensureDir(r(`extension/dist/${view}`)); diff --git a/src/components/ResultTable.vue b/src/components/ResultTable.vue new file mode 100644 index 0000000..6c37f52 --- /dev/null +++ b/src/components/ResultTable.vue @@ -0,0 +1,186 @@ + + + + + diff --git a/src/logic/data-io.ts b/src/logic/data-io.ts index 4a81bcb..9ec9cc0 100644 --- a/src/logic/data-io.ts +++ b/src/logic/data-io.ts @@ -1,4 +1,4 @@ -import { utils, writeFileXLSX } from 'xlsx'; +import { utils, read, writeFileXLSX } from 'xlsx'; /** * 导出为XLSX文件 @@ -7,19 +7,13 @@ import { utils, writeFileXLSX } from 'xlsx'; */ export function exportToXLSX( data: Record[], - options: { fileName?: string; headers?: { label: string; prop: string }[]; index?: boolean } = {}, + options: { fileName?: string; headers?: { label: string; prop: string }[] } = {}, ): void { if (!data.length) { return; } const headers = options.headers || Object.keys(data[0]).map((k) => ({ label: k, prop: k })); - if (options.index) { - headers.unshift({ label: 'Index', prop: 'Index' }); - data.forEach((item, index) => { - item.Index = index + 1; - }); - } const rows = data.map((item) => { const row: Record = {}; headers.forEach((header) => { @@ -36,3 +30,47 @@ export function exportToXLSX( const fileName = options.fileName || `export_${new Date().toISOString().slice(0, 10)}.xlsx`; writeFileXLSX(workbook, fileName, { bookType: 'xlsx', type: 'binary', compression: true }); } + +/** + * 从XLSX文件导入数据 + * @param file XLSX文件对象 + * @param options 导入选项 + * @returns 导入的数据数组 + */ +export async function importFromXLSX>( + file: File, + options: { headers?: { label: string; prop: string }[] } = {}, +): Promise { + 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(worksheet); + + if (options.headers) { + jsonData = jsonData.map((item) => { + const mappedItem: Record = {}; + options.headers?.forEach((header) => { + mappedItem[header.prop] = item[header.label]; + }); + return mappedItem as T; + }); + } + resolve(jsonData); + } catch (error) { + reject(error); + } + }; + + reader.onerror = (error) => { + reject(error); + }; + + reader.readAsArrayBuffer(file); + }); +} diff --git a/src/logic/page-worker/index.ts b/src/logic/page-worker/index.ts index c6004fe..9ccb616 100644 --- a/src/logic/page-worker/index.ts +++ b/src/logic/page-worker/index.ts @@ -8,6 +8,7 @@ import { exec } from '../execute-script'; * **can't** run on content script! */ class AmazonPageWorkerImpl implements AmazonPageWorker { + //#region Singleton private static _instance: AmazonPageWorker | null = null; public static getInstance() { if (this._instance === null) { @@ -16,9 +17,18 @@ class AmazonPageWorkerImpl implements AmazonPageWorker { return this._instance; } private constructor() {} + //#endregion + /** + * The channel for communication with the Amazon page worker. + */ readonly channel = new Emittery(); + /** + * The signal to interrupt the current operation. + */ + private _interruptSignal = false; + private async getCurrentTab(): Promise { const tab = await browser.tabs .query({ active: true, currentWindow: true }) @@ -26,21 +36,6 @@ class AmazonPageWorkerImpl implements AmazonPageWorker { return tab; } - public async doSearch(keywords: string): Promise { - const url = new URL('https://www.amazon.com/s'); - url.searchParams.append('k', keywords); - - const tab = await browser.tabs - .query({ active: true, currentWindow: true }) - .then((tabs) => tabs[0]); - const currentUrl = new URL(tab.url!); - if (currentUrl.hostname !== url.hostname || currentUrl.searchParams.get('k') !== keywords) { - await browser.tabs.update(tab.id, { url: url.toString() }); - await new Promise((resolve) => setTimeout(resolve, 1000)); - } - return url.toString(); - } - private async wanderSearchSinglePage(tab: Tabs.Tab) { const tabId = tab.id!; // #region Wait for the Next button to appear, indicating that the product items have finished loading @@ -142,23 +137,39 @@ class AmazonPageWorkerImpl implements AmazonPageWorker { return { data, hasNextPage }; } + public async doSearch(keywords: string): Promise { + const url = new URL('https://www.amazon.com/s'); + url.searchParams.append('k', keywords); + + const tab = await browser.tabs + .query({ active: true, currentWindow: true }) + .then((tabs) => tabs[0]); + const currentUrl = new URL(tab.url!); + if (currentUrl.hostname !== url.hostname || currentUrl.searchParams.get('k') !== keywords) { + await browser.tabs.update(tab.id, { url: url.toString() }); + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + return url.toString(); + } + public async wanderSearchPage(): Promise { const tab = await this.getCurrentTab(); - let stopSignal = false; + this._interruptSignal = false; const stop = async (_: unknown): Promise => { - stopSignal = true; + this._interruptSignal = true; }; this.channel.on('error', stop); let result = { hasNextPage: true, data: [] as unknown[], }; - while (result.hasNextPage && !stopSignal) { + while (result.hasNextPage && !this._interruptSignal) { result = await this.wanderSearchSinglePage(tab); this.channel.emit('item-links-collected', { objs: result.data as { link: string; title: string; imageSrc: string }[], }); } + this._interruptSignal = false; this.channel.off('error', stop); return new Promise((resolve) => setTimeout(resolve, 1000)); } @@ -248,9 +259,11 @@ class AmazonPageWorkerImpl implements AmazonPageWorker { } if (rawRankingText) { const [category1Statement, category2Statement] = rawRankingText.split('\n'); - const category1Ranking = Number(/(?<=#)\d+/.exec(category1Statement)?.[0]) || null; + const category1Ranking = + Number(/(?<=#)[0-9,]+/.exec(category1Statement)?.[0].replace(',', '')) || null; // "," should be removed const category1Name = /(?<=in\s).+(?=\s\(See)/.exec(category1Statement)?.[0] || null; - const category2Ranking = Number(/(?<=#)\d+/.exec(category2Statement)?.[0]) || null; + const category2Ranking = + Number(/(?<=#)[0-9,]+/.exec(category2Statement)?.[0].replace(',', '')) || null; // "," should be removed const category2Name = /(?<=in\s).+/.exec(category2Statement)?.[0] || null; this.channel.emit('item-category-rank-collected', { asin: params.asin, @@ -265,14 +278,27 @@ class AmazonPageWorkerImpl implements AmazonPageWorker { //#endregion //#region Fetch Goods' Images const imageUrls = await exec(tab.id!, async () => { - const node = document.evaluate( - `//div[@id='imgTagWrapperId']/img`, - document, - null, - XPathResult.FIRST_ORDERED_NODE_TYPE, - null, - ).singleNodeValue as HTMLImageElement | null; - return node ? [node.getAttribute('src')!] : null; + let urls = [ + ...(document.querySelectorAll('.imageThumbnail img') as unknown as HTMLImageElement[]), + ].map((e) => e.src); + // https://github.com/primedigitaltech/azon_seeker/issues/4 + if (document.querySelector('.overlayRestOfImages')) { + const overlay = document.querySelector('.overlayRestOfImages')!; + if (document.querySelector('#ivThumbs')!.getClientRects().length === 0) { + overlay.click(); + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + urls = [ + ...(document.querySelectorAll( + '#ivThumbs .ivThumbImage[style]', + ) as unknown as HTMLDivElement[]), + ].map((e) => e.style.background); + urls = urls.map((s) => { + const [url] = /(?<=url\(").+(?=")/.exec(s)!; + return url; + }); + } + return urls; }); imageUrls && this.channel.emit('item-images-collected', { @@ -281,6 +307,10 @@ class AmazonPageWorkerImpl implements AmazonPageWorker { }); //#endregion } + + public async stop(): Promise { + this._interruptSignal = true; + } } class PageWorkerFactory { diff --git a/src/logic/page-worker/types.d.ts b/src/logic/page-worker/types.d.ts index 429d10b..c3d0907 100644 --- a/src/logic/page-worker/types.d.ts +++ b/src/logic/page-worker/types.d.ts @@ -70,4 +70,9 @@ interface AmazonPageWorker { * @param entry Product link or Amazon Standard Identification Number. */ wanderDetailPage(entry: string): Promise; + + /** + * Stop the worker. + */ + stop(): Promise; } diff --git a/src/manifest.ts b/src/manifest.ts index 6bb2184..bac2a21 100644 --- a/src/manifest.ts +++ b/src/manifest.ts @@ -16,6 +16,10 @@ export async function getManifest() { action: { default_icon: './assets/icon-512.png', }, + options_ui: { + page: './dist/options/index.html', + open_in_tab: true, + }, background: isFirefox ? { scripts: ['dist/background/index.mjs'], @@ -63,14 +67,5 @@ export async function getManifest() { }; } - // FIXME: not work in MV3 - if (isDev && false) { - // for content script, as browsers will cache them for each reload, - // we use a background script to always inject the latest version - // see src/background/contentScriptHMR.ts - delete manifest.content_scripts; - manifest.permissions?.push('webNavigation'); - } - return manifest; } diff --git a/src/options/App.vue b/src/options/App.vue new file mode 100644 index 0000000..e893754 --- /dev/null +++ b/src/options/App.vue @@ -0,0 +1,25 @@ + + + diff --git a/src/options/Options.vue b/src/options/Options.vue new file mode 100644 index 0000000..e49890b --- /dev/null +++ b/src/options/Options.vue @@ -0,0 +1,20 @@ + + + + + diff --git a/src/options/index.html b/src/options/index.html new file mode 100644 index 0000000..c1bafd5 --- /dev/null +++ b/src/options/index.html @@ -0,0 +1,12 @@ + + + + + + Options + + +
+ + + diff --git a/src/options/main.ts b/src/options/main.ts new file mode 100644 index 0000000..50823b8 --- /dev/null +++ b/src/options/main.ts @@ -0,0 +1,7 @@ +import App from './App.vue'; +import { setupApp } from '~/logic/common-setup'; +import '../styles'; + +const app = createApp(App); +setupApp(app); +app.mount('#app'); diff --git a/src/sidepanel/App.vue b/src/sidepanel/App.vue index 4075807..d24df3a 100644 --- a/src/sidepanel/App.vue +++ b/src/sidepanel/App.vue @@ -4,11 +4,22 @@ import SidePanel from './SidePanel.vue'; diff --git a/src/sidepanel/DetailPageWorker.vue b/src/sidepanel/DetailPageWorker.vue index 16d7cff..44a7d95 100644 --- a/src/sidepanel/DetailPageWorker.vue +++ b/src/sidepanel/DetailPageWorker.vue @@ -32,8 +32,8 @@ const handleGetInfo = async () => { - Get Info -
{{ data }}
+ Get Info + {{ data }} diff --git a/src/sidepanel/SearchPageWorker.vue b/src/sidepanel/SearchPageWorker.vue index 5e8f345..424a243 100644 --- a/src/sidepanel/SearchPageWorker.vue +++ b/src/sidepanel/SearchPageWorker.vue @@ -1,83 +1,30 @@
- 采集 + + +
- - - + + + + {{ item.content }} + + + - + - - - - @@ -197,16 +158,30 @@ const handleExport = async () => { align-items: center; gap: 20px; - .app-header { - margin-top: 100px; + .header-menu { + width: 95%; + display: flex; + flex-direction: row-reverse; + justify-content: flex-start; + + .setting-button { + opacity: 0.7; + &:hover { + opacity: 1; + } + } + } + + .app-title { + margin-top: 60px; } .search-input-box { - min-width: 270px; + min-width: 240px; } - .result-content-container { - width: 90%; + .progress-report { + width: 95%; } } diff --git a/src/sidepanel/Sidepanel.vue b/src/sidepanel/Sidepanel.vue index 01b95cb..80c256c 100644 --- a/src/sidepanel/Sidepanel.vue +++ b/src/sidepanel/Sidepanel.vue @@ -4,11 +4,11 @@ import SearchPageWorker from './SearchPageWorker.vue'; const tabs = [ { - name: 'Search Page', + name: '搜索页', component: SearchPageWorker, }, { - name: 'Detail Page', + name: '详情页', component: DetailPageWorker, }, ]; diff --git a/src/sidepanel/index.html b/src/sidepanel/index.html index 57e2897..ecc7f1d 100644 --- a/src/sidepanel/index.html +++ b/src/sidepanel/index.html @@ -5,7 +5,7 @@ Sidepanel - +