feature: split 2 patterns of search page & declare wanderDetailPage function

This commit is contained in:
johnathan 2025-04-17 16:43:13 +08:00
parent 94fa61dfa1
commit 1dc76ffc20
12 changed files with 256 additions and 93 deletions

3
.gitignore vendored
View File

@ -15,3 +15,6 @@ node_modules
src/auto-imports.d.ts src/auto-imports.d.ts
src/components.d.ts src/components.d.ts
.eslintcache .eslintcache
**/test_data.ts
**/TestPanel.vue

View File

@ -1,3 +1,4 @@
{ {
"singleQuote": true "singleQuote": true,
"printWidth": 100
} }

View File

@ -1,9 +1,3 @@
{ {
"recommendations": [ "recommendations": ["vue.volar"]
"vue.volar"
// "antfu.iconify",
// "antfu.unocss",
// "dbaeumer.vscode-eslint",
// "csstools.postcss"
]
} }

16
.vscode/launch.json vendored Normal file
View File

@ -0,0 +1,16 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "msedge",
"request": "attach",
"name": "Attach to side panel",
"webRoot": "${workspaceFolder}/src/",
"port": 9222,
"urlFilter": "chrome-extension://*"
}
]
}

View File

@ -1,12 +1,11 @@
{ {
"cSpell.words": ["Vitesse"],
"typescript.tsdk": "node_modules/typescript/lib", "typescript.tsdk": "node_modules/typescript/lib",
"vite.autoStart": false,
"editor.codeActionsOnSave": { "editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit" "source.fixAll.eslint": "explicit"
}, },
"files.associations": { "files.associations": {
"*.css": "postcss" "*.css": "postcss"
}, },
"prettier.tabWidth": 2 "prettier.tabWidth": 2,
"prettier.printWidth": 100
} }

View File

@ -0,0 +1,17 @@
/**
*
* @param tabId
* @param func
* @returns
*/
export async function executeScript<T>(tabId: number, func: () => Promise<T>): Promise<T | null> {
const injectResults = await browser.scripting.executeScript({
target: { tabId },
func,
});
const ret = injectResults.pop();
if (ret?.error) {
console.error('注入脚本时发生错误', ret.error);
}
return ret?.result as T | null;
}

View File

@ -1,5 +1,7 @@
import Emittery from 'emittery'; import Emittery from 'emittery';
import { AmazonPageWorker, AmazonPageWorkerEvents } from './types'; import type { AmazonGoodsLinkItem, AmazonPageWorker, AmazonPageWorkerEvents } from './types';
import Browser from 'webextension-polyfill';
import { executeScript } from '../execute-script';
class AmazonPageWorkerImpl implements AmazonPageWorker { class AmazonPageWorkerImpl implements AmazonPageWorker {
readonly channel = new Emittery<AmazonPageWorkerEvents>(); readonly channel = new Emittery<AmazonPageWorkerEvents>();
@ -12,70 +14,123 @@ class AmazonPageWorkerImpl implements AmazonPageWorker {
.query({ active: true, currentWindow: true }) .query({ active: true, currentWindow: true })
.then((tabs) => tabs[0]); .then((tabs) => tabs[0]);
const currentUrl = new URL(tab.url!); const currentUrl = new URL(tab.url!);
if ( if (currentUrl.hostname !== url.hostname || currentUrl.searchParams.get('k') !== keywords) {
currentUrl.hostname !== url.hostname ||
currentUrl.searchParams.get('k') !== keywords
) {
await browser.tabs.update(tab.id, { url: url.toString() }); await browser.tabs.update(tab.id, { url: url.toString() });
await new Promise<void>((resolve) => setTimeout(resolve, 1000));
} }
return url.toString(); return url.toString();
} }
private async wanderSearchSinglePage() { private async wanderSearchSinglePage(tab: Browser.Tabs.Tab) {
const tab = await browser.tabs const tabId = tab.id!;
.query({ active: true, currentWindow: true }) // #region Wait for the Next button to appear, indicating that the product items have finished loading
.then((tabs) => tabs[0]); await executeScript(tabId, async () => {
const results = await browser.scripting.executeScript({ await new Promise((resolve) => setTimeout(resolve, 500 + ~~(500 * Math.random())));
target: { tabId: tab.id! },
func: async () => {
try {
await new Promise((resolve) =>
setTimeout(resolve, 500 + ~~(500 * Math.random())),
);
while (!document.querySelector('.s-pagination-strip')) { while (!document.querySelector('.s-pagination-strip')) {
window.scrollBy(0, ~~(Math.random() * 500) + 500); window.scrollBy(0, ~~(Math.random() * 500) + 500);
await new Promise((resolve) => setTimeout(resolve, 10)); await new Promise((resolve) => setTimeout(resolve, 10));
} }
const items = document.querySelectorAll<HTMLDivElement>( });
// #endregion
// #region Determine the type of product search page https://github.com/primedigitaltech/azon_seeker/issues/1
const pagePattern = await executeScript(tabId, async () => {
return [
...(document.querySelectorAll<HTMLDivElement>(
'.a-section.a-spacing-small.puis-padding-left-small', '.a-section.a-spacing-small.puis-padding-left-small',
); ) as unknown as HTMLDivElement[]),
const links: string[] = []; ].filter((e) => e.getClientRects().length > 0).length === 0
items.forEach((el) => { ? 'pattern-1'
const link = : 'pattern-2';
el.querySelector<HTMLAnchorElement>('a.a-link-normal')?.href;
link && links.push(link);
}); });
const nextButton = if (typeof pagePattern !== 'string') {
document.querySelector<HTMLLinkElement>('.s-pagination-next'); this.channel.emit('error', { message: '无法判断商品搜索页类型', url: tab.url });
if ( throw new Error('无法判断商品搜索页类型');
nextButton && }
!nextButton.classList.contains('s-pagination-disabled') // #endregion
) { // #region Retrieve key nodes and their information from the critical product search page
await new Promise((resolve) => let data: AmazonGoodsLinkItem[] | null = null;
setTimeout(resolve, 500 + ~~(500 * Math.random())), switch (pagePattern) {
); // 处理商品以列表形式展示的情况
case 'pattern-1':
data = await executeScript(tabId, async () => {
const items = [
...(document.querySelectorAll<HTMLDivElement>(
'.a-section.a-spacing-small.a-spacing-top-small:not(.a-text-right)',
) as unknown as HTMLDivElement[]),
].filter((e) => e.getClientRects().length > 0);
const linkObjs = items.reduce<AmazonGoodsLinkItem[]>((objs, el) => {
const link = el.querySelector<HTMLAnchorElement>('a')?.href;
const title = el
.querySelector<HTMLHeadingElement>('h2.a-color-base')
?.getAttribute('aria-label');
link && objs.push({ link, title: title || '' });
return objs;
}, []);
return linkObjs;
});
break;
// 处理商品以二维图片格展示的情况
case 'pattern-2':
data = await executeScript(tabId, async () => {
const items = [
...(document.querySelectorAll<HTMLDivElement>(
'.a-section.a-spacing-small.puis-padding-left-small',
) as unknown as HTMLDivElement[]),
].filter((e) => e.getClientRects().length > 0);
const linkObjs = items.reduce<AmazonGoodsLinkItem[]>((objs, el) => {
const link = el.querySelector<HTMLAnchorElement>('a.a-link-normal')?.href;
const title = el.querySelector<HTMLHeadingElement>('h2.a-color-base')?.innerText;
link && objs.push({ link, title: title || '' });
return objs;
}, []);
return linkObjs;
});
break;
default:
break;
}
// #endregion
// #region Determine if it is the last page, otherwise navigate to the next page
const hasNextPage = await executeScript(tabId, async () => {
const nextButton = document.querySelector<HTMLLinkElement>('.s-pagination-next');
if (nextButton) {
if (!nextButton.classList.contains('s-pagination-disabled')) {
await new Promise((resolve) => setTimeout(resolve, 500 + ~~(500 * Math.random())));
nextButton.click(); nextButton.click();
return true;
} else { } else {
return null; return false;
} }
return links; } else {
} catch (e) { throw new Error('Error: next page button not found');
return null;
} }
},
}); });
// #endregion
await new Promise((resolve) => setTimeout(resolve, 1000)); await new Promise((resolve) => setTimeout(resolve, 1000));
return results.pop()?.result as string[] | null; if (data === null || typeof hasNextPage !== 'boolean') {
this.channel.emit('error', { message: '爬取单页信息失败', url: tab.url });
throw new Error('爬取单页信息失败');
}
return { data, hasNextPage };
} }
public async wanderSearchList(): Promise<void> { public async wanderSearchList(): Promise<void> {
let links = await this.wanderSearchSinglePage(); const tab = await browser.tabs
while (links) { .query({ active: true, currentWindow: true })
this.channel.emit('item-links-collected', { links }); .then((tabs) => tabs[0]);
links = await this.wanderSearchSinglePage(); let stopSignal = false;
let result = { hasNextPage: true, data: [] as AmazonGoodsLinkItem[] };
while (result.hasNextPage && !stopSignal) {
result = await this.wanderSearchSinglePage(tab);
this.channel.emit('item-links-collected', { objs: result.data });
this.channel.on('error', () => {
stopSignal = true;
});
} }
return new Promise((resolve) => setTimeout(resolve, 1000)); return new Promise((resolve) => setTimeout(resolve, 1000));
} }
public async wanderDetailPage(): Promise<void> {}
} }
class PageWorkerFactory { class PageWorkerFactory {

View File

@ -1,13 +1,18 @@
import type Emittery from 'emittery'; import type Emittery from 'emittery';
type AmazonGoodsLinkItem = { link: string; title: string };
interface AmazonPageWorkerEvents { interface AmazonPageWorkerEvents {
/** /**
* Emitted when a new item is found on the Amazon page. * This event is used to collect links to items on the Amazon search page.
* @param link - The item link that was found.
*/ */
['item-links-collected']: { links: string[] }; ['item-links-collected']: { objs: AmazonGoodsLinkItem[] };
}
/**
* Error event that occurs when there is an issue with the Amazon page worker.
*/
['error']: { message: string; url?: string };
}
interface AmazonPageWorker { interface AmazonPageWorker {
/** /**
@ -17,15 +22,19 @@ interface AmazonPageWorker {
readonly channel: Emittery<AmazonPageWorkerEvents>; readonly channel: Emittery<AmazonPageWorkerEvents>;
/** /**
* Search for a list of items on Amazon * Search for a list of goods on Amazon
* @param keywords - The keywords to search for on Amazon. * @param keywords - The keywords to search for on Amazon.
* @returns A promise that resolves to a string representing the search URL. * @returns A promise that resolves to a string representing the search URL.
*/ */
doSearch(keywords: string): Promise<string>; doSearch(keywords: string): Promise<string>;
/** /**
* Browsing item search page and collect links to those items. * Browsing goods search page and collect links to those goods.
* @param entryUrl - The URL of the Amazon search page to start from.
*/ */
wanderSearchList(): Promise<void>; wanderSearchList(): Promise<void>;
/**
* Browsing goods detail page and collect target information.
*/
wanderDetailPage(): Promise<void>;
} }

11
src/sidepanel/App.vue Normal file
View File

@ -0,0 +1,11 @@
<script lang="ts" setup>
import Sidepanel from './Sidepanel.vue';
</script>
<template>
<n-dialog-provider>
<n-message-provider>
<sidepanel />
</n-message-provider>
</n-dialog-provider>
</template>

View File

@ -1,28 +1,85 @@
<script setup lang="ts"> <script setup lang="ts">
import { keywords } from '~/logic/storage'; import { keywords } from '~/logic/storage';
import pageWorker from '~/logic/page-worker'; import pageWorker from '~/logic/page-worker';
import type { AmazonGoodsLinkItem } from '~/logic/page-worker/types';
import { NButton, type DataTableColumns } from 'naive-ui';
const links = ref<string[]>([]); const message = useMessage();
const worker = pageWorker.createAmazonPageWorker(); const worker = pageWorker.createAmazonPageWorker();
type TableData = AmazonGoodsLinkItem & { rank: number };
const items = ref<AmazonGoodsLinkItem[]>([]);
const page = reactive({ current: 1, size: 5 });
const columns: DataTableColumns<TableData> = [
{
title: '排位',
key: 'rank',
},
{
title: '标题',
key: 'title',
},
{
title: '链接',
key: 'link',
render(row) {
return h(
NButton,
{
type: 'primary',
text: true,
size: 'small',
onClick: async () => {
const tab = await browser.tabs
.query({
active: true,
currentWindow: true,
})
.then((ts) => ts.pop());
if (tab) {
await browser.tabs.update(tab.id, {
url: row.link,
});
}
},
},
() => '前往',
);
},
},
];
const itemView = computed(() => {
const { current, size } = page;
return items.value
.slice((current - 1) * size, current * size)
.map((v, i) => ({ ...v, rank: 1 + (current - 1) * size + i }));
});
onMounted(() => { onMounted(() => {
worker.channel.on('item-links-collected', (ev) => { worker.channel.on('item-links-collected', (ev) => {
links.value = links.value.concat(ev.links); items.value = items.value.concat(ev.objs);
}); });
}); });
const onSearch = async () => { const onCollect = async () => {
if (keywords.value.trim() === '') { if (keywords.value.trim() === '') {
return; return;
} }
message.info('开始收集');
worker.channel.on('error', ({ message: msg }) => {
message.error(msg);
});
await worker.doSearch(keywords.value); await worker.doSearch(keywords.value);
await worker.wanderSearchList(); await worker.wanderSearchList();
message.info('完成');
}; };
</script> </script>
<template> <template>
<main class="side-panel"> <main class="side-panel">
<n-space> <n-space class="app-header">
<mdi-cat style="font-size: 60px; color: black" /> <mdi-cat style="font-size: 60px; color: black" />
<h1>Azon Seeker</h1> <h1>Azon Seeker</h1>
</n-space> </n-space>
@ -35,22 +92,27 @@ const onSearch = async () => {
round round
placeholder="请输入关键词" placeholder="请输入关键词"
/> />
<n-button round size="large" @click="onSearch">搜索</n-button> <n-button type="primary" round size="large" @click="onCollect">采集</n-button>
</n-space> </n-space>
<div style="height: 10px"></div> <div style="height: 10px"></div>
<n-card class="result-content-container" title="结果框"> <n-card class="result-content-container" title="结果框">
<n-empty v-if="links.length === 0" description="还没有结果哦"> <n-empty v-if="items.length === 0" description="还没有结果哦">
<template #icon> <template #icon>
<n-icon :size="50"> <n-icon :size="50">
<solar-cat-linear /> <solar-cat-linear />
</n-icon> </n-icon>
</template> </template>
</n-empty> </n-empty>
<n-list size="medium" v-else> <n-space vertical v-else>
<n-list-item v-for="(link, index) in links" :key="index"> <n-data-table :columns="columns" :data="itemView" />
<n-tag :href="link" target="_blank">{{ link }}</n-tag> <n-pagination
</n-list-item> v-model:page="page.current"
</n-list> v-model:page-size="page.size"
:page-count="~~(items.length / page.size) + 1"
:page-sizes="[5, 10, 20]"
show-size-picker
/>
</n-space>
</n-card> </n-card>
</main> </main>
</template> </template>
@ -58,13 +120,16 @@ const onSearch = async () => {
<style lang="scss" scoped> <style lang="scss" scoped>
.side-panel { .side-panel {
width: 100%; width: 100%;
height: 100vh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
gap: 20px; gap: 20px;
.app-header {
margin-top: 100px;
}
.search-input-box { .search-input-box {
min-width: 270px; min-width: 270px;
} }

View File

@ -1,4 +1,4 @@
import App from './Sidepanel.vue'; import App from './App.vue';
import { setupApp } from '~/logic/common-setup'; import { setupApp } from '~/logic/common-setup';
import '../styles'; import '../styles';

View File

@ -33,12 +33,7 @@ export const sharedConfig: UserConfig = {
'webextension-polyfill': [['=', 'browser']], 'webextension-polyfill': [['=', 'browser']],
}, },
{ {
'naive-ui': [ 'naive-ui': ['useDialog', 'useMessage', 'useNotification', 'useLoadingBar'],
'useDialog',
'useMessage',
'useNotification',
'useLoadingBar',
],
}, },
], ],
dts: r('src/auto-imports.d.ts'), dts: r('src/auto-imports.d.ts'),
@ -54,6 +49,7 @@ export const sharedConfig: UserConfig = {
IconsResolver({ IconsResolver({
prefix: '', prefix: '',
}), }),
// auto import naive ui
NaiveUiResolver(), NaiveUiResolver(),
], ],
}), }),
@ -67,10 +63,7 @@ export const sharedConfig: UserConfig = {
enforce: 'post', enforce: 'post',
apply: 'build', apply: 'build',
transformIndexHtml(html, { path }) { transformIndexHtml(html, { path }) {
return html.replace( return html.replace(/"\/assets\//g, `"${relative(dirname(path), '/assets')}/`);
/"\/assets\//g,
`"${relative(dirname(path), '/assets')}/`,
);
}, },
}, },
], ],