Skip to content

Commit 5dc47ab

Browse files
authored
feat(route/mercari): add advanced search route with flexible query parameters (#20215)
1 parent ae08e37 commit 5dc47ab

File tree

3 files changed

+118
-11
lines changed

3 files changed

+118
-11
lines changed

lib/routes/mercari/keyword.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,8 @@ export const route: Route = {
5555

5656
async function handler(ctx) {
5757
const { sort, order, status, keyword } = ctx.req.param();
58-
const searchItems = (await fetchSearchItems(MercariSort[sort], MercariOrder[order], MercariStatus[status], keyword)).items;
58+
const statusArray = MercariStatus[status] ? [MercariStatus[status]] : [];
59+
const searchItems = (await fetchSearchItems(MercariSort[sort], MercariOrder[order], statusArray, keyword)).items;
5960
const items = await Promise.all(searchItems.map((item) => cache.tryGet(`mercari:${item.id}`, async () => await fetchItemDetail(item.id, item.itemType).then((detail) => formatItemDetail(detail)))));
6061

6162
return {

lib/routes/mercari/search.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { Route } from '@/types';
2+
import cache from '@/utils/cache';
3+
import { fetchSearchItems, fetchItemDetail, formatItemDetail, MercariSort, MercariOrder, MercariStatus } from './util';
4+
5+
export const route: Route = {
6+
path: '/search/:query',
7+
categories: ['shopping'],
8+
example: '/mercari/search/keyword=シャツ&7bd3eacc-ae45-4d73-bc57-a611c9432014=340258ac-e220-4722-8c35-7f73b7382831',
9+
parameters: {
10+
query: 'Search parameters in URL query string format.',
11+
},
12+
features: {
13+
requireConfig: false,
14+
requirePuppeteer: false,
15+
antiCrawler: false,
16+
supportBT: false,
17+
supportPodcast: false,
18+
supportScihub: false,
19+
},
20+
name: 'Search',
21+
maintainers: ['yana9i, Tsuyumi25'],
22+
url: 'jp.mercari.com',
23+
handler,
24+
};
25+
26+
function parseSearchQuery(queryString: string) {
27+
const params = new URLSearchParams(queryString);
28+
29+
const keyword = params.get('keyword') || '';
30+
const sort = MercariSort[params.get('sort') as keyof typeof MercariSort] || MercariSort.default;
31+
const order = MercariOrder[params.get('order') as keyof typeof MercariOrder] || MercariOrder.desc;
32+
33+
const statusMap: Record<string, keyof typeof MercariStatus> = {
34+
on_sale: 'onsale',
35+
'sold_out|trading': 'soldout',
36+
};
37+
const statusArray =
38+
params
39+
.get('status')
40+
?.split(',')
41+
.map((s) => MercariStatus[statusMap[s]])
42+
.filter(Boolean) || [];
43+
44+
const attributeIds = [
45+
'7bd3eacc-ae45-4d73-bc57-a611c9432014', // 色
46+
'47295d80-5839-4237-bbfc-deb44b4e7999', // 割引オプション
47+
'f42ae390-04ff-46ea-808b-f5d97cb45db4', // サイズ
48+
'd664efe3-ae5a-4824-b729-e789bf93aba9', // 出品形式
49+
];
50+
51+
const attributes: Array<{ id: string; values: string[] }> = [];
52+
for (const id of attributeIds) {
53+
const values = params.get(id);
54+
if (values) {
55+
attributes.push({ id, values: values.split(',') });
56+
}
57+
}
58+
59+
const options = {
60+
categoryId: params.get('category_id')?.split(',').map(Number),
61+
brandId: params.get('brand_id')?.split(',').map(Number),
62+
priceMin: params.get('price_min') ? Number(params.get('price_min')) : undefined,
63+
priceMax: params.get('price_max') ? Number(params.get('price_max')) : undefined,
64+
itemConditionId: params.get('item_condition_id')?.split(',').map(Number),
65+
excludeKeyword: params.get('exclude_keyword') || undefined,
66+
itemTypes: params
67+
.get('item_types')
68+
?.split(',')
69+
.map((type) => (type === 'mercari' ? 'ITEM_TYPE_MERCARI' : type === 'beyond' ? 'ITEM_TYPE_BEYOND' : type)),
70+
attributes,
71+
};
72+
73+
return { keyword, sort, order, status: statusArray, options };
74+
}
75+
76+
async function handler(ctx) {
77+
const queryString = ctx.req.param('query');
78+
79+
const { keyword, sort, order, status, options } = parseSearchQuery(queryString);
80+
const searchItems = (await fetchSearchItems(sort, order, status, keyword, options)).items;
81+
82+
const items = await Promise.all(searchItems.map((item) => cache.tryGet(`mercari:${item.id}`, async () => await fetchItemDetail(item.id, item.itemType).then((detail) => formatItemDetail(detail)))));
83+
84+
return {
85+
title: `${keyword} の検索結果`,
86+
link: `https://jp.mercari.com/search?${queryString}`,
87+
description: `Mercari advanced search results for: ${queryString}`,
88+
item: items,
89+
};
90+
}

lib/routes/mercari/util.ts

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { parseDate } from '@/utils/parse-date';
66
import { art } from '@/utils/render';
77
import path from 'node:path';
88
import { DataItem } from '@/types';
9+
import logger from '@/utils/logger';
910

1011
const rootURL = 'https://api.mercari.jp/';
1112
const rootProductURL = 'https://jp.mercari.com/item/';
@@ -192,7 +193,21 @@ const pageToPageToken = (page: number): string => {
192193
return `v1:${page}`;
193194
};
194195

195-
const fetchSearchItems = async (sort: string, order: string, status: string, keyword: string): Promise<SearchResponse> => {
196+
interface SearchOptions {
197+
categoryId?: number[];
198+
brandId?: number[];
199+
priceMin?: number;
200+
priceMax?: number;
201+
itemConditionId?: number[];
202+
excludeKeyword?: string;
203+
itemTypes?: string[];
204+
attributes?: Array<{
205+
id: string;
206+
values: string[];
207+
}>;
208+
}
209+
210+
const fetchSearchItems = async (sort: string, order: string, status: string[], keyword: string, options: SearchOptions = {}): Promise<SearchResponse> => {
196211
const data = {
197212
userId: `MERCARI_BOT_${uuidv4()}`,
198213
pageSize: 120,
@@ -202,24 +217,24 @@ const fetchSearchItems = async (sort: string, order: string, status: string, key
202217
thumbnailTypes: [],
203218
searchCondition: {
204219
keyword,
205-
excludeKeyword: '',
220+
excludeKeyword: options.excludeKeyword || '',
206221
sort,
207222
order,
208-
status: [],
223+
status: status || [],
209224
sizeId: [],
210-
categoryId: [],
211-
brandId: [],
225+
categoryId: options.categoryId || [],
226+
brandId: options.brandId || [],
212227
sellerId: [],
213-
priceMin: 0,
214-
priceMax: 0,
215-
itemConditionId: [],
228+
priceMin: options.priceMin || 0,
229+
priceMax: options.priceMax || 0,
230+
itemConditionId: options.itemConditionId || [],
216231
shippingPayerId: [],
217232
shippingFromArea: [],
218233
shippingMethod: [],
219234
colorId: [],
220235
hasCoupon: false,
221-
attributes: [],
222-
itemTypes: [],
236+
attributes: options.attributes || [],
237+
itemTypes: options.itemTypes || [],
223238
skuIds: [],
224239
shopIds: [],
225240
excludeShippingMethodIds: [],
@@ -240,6 +255,7 @@ const fetchSearchItems = async (sort: string, order: string, status: string, key
240255
withAuction: true,
241256
};
242257

258+
logger.debug(JSON.stringify(data));
243259
return await fetchFromMercari<SearchResponse>(searchURL, data, 'POST');
244260
};
245261

0 commit comments

Comments
 (0)