rsshub
Version:
Make RSS Great Again!
323 lines (285 loc) • 9.86 kB
text/typescript
import crypto from 'node:crypto';
import { Buffer } from 'node:buffer';
import ofetch from '@/utils/ofetch';
import { SearchResponse, ItemDetail, ShopItemDetail } from './types';
import { parseDate } from '@/utils/parse-date';
import { art } from '@/utils/render';
import path from 'node:path';
import { DataItem } from '@/types';
import logger from '@/utils/logger';
const rootURL = 'https://api.mercari.jp/';
const rootProductURL = 'https://jp.mercari.com/item/';
const rootShopProductURL = 'https://jp.mercari.com/shops/product/';
const searchURL = `${rootURL}v2/entities:search`;
const itemInfoURL = `${rootURL}items/get`;
const shopItemInfoURL = `${rootURL}v1/marketplaces/shops/products/`;
const uuidv4 = () => crypto.randomUUID();
const MercariStatus = {
default: '',
onsale: 'STATUS_ON_SALE',
soldout: 'STATUS_SOLD_OUT',
} as const;
const MercariSort = {
default: 'SORT_DEFAULT',
create_time: 'SORT_CREATED_TIME',
like: 'SORT_NUM_LIKES',
score: 'SORT_SCORE',
price: 'SORT_PRICE',
} as const;
const MercariOrder = {
desc: 'ORDER_DESC',
asc: 'ORDER_ASC',
} as const;
function bytesToBase64URL(b: Buffer): string {
return b.toString('base64').replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', '');
}
function strToBase64URL(s: string): string {
return bytesToBase64URL(Buffer.from(s, 'utf-8'));
}
function publicKeyToJWK(publicKey: crypto.KeyObject): any {
const jwk = publicKey.export({ format: 'jwk' }) as { x: string; y: string };
return {
crv: 'P-256',
kty: 'EC',
x: jwk.x,
y: jwk.y,
};
}
function publicKeyToHeader(publicKey: crypto.KeyObject): any {
return {
typ: 'dpop+jwt',
alg: 'ES256',
jwk: publicKeyToJWK(publicKey),
};
}
function derDecode(der: Buffer): { r: Buffer; s: Buffer } {
let offset = 0;
if (der[offset++] !== 0x30) {
throw new Error('Invalid DER signature');
}
const lenInfo = readDerLength(der, offset);
offset += lenInfo.bytesRead;
if (der[offset++] !== 0x02) {
throw new Error('Expected INTEGER for R');
}
const rLen = readDerLength(der, offset);
offset += rLen.bytesRead;
const r = der.subarray(offset, offset + rLen.length);
offset += rLen.length;
if (der[offset++] !== 0x02) {
throw new Error('Expected INTEGER for S');
}
const sLen = readDerLength(der, offset);
offset += sLen.bytesRead;
const s = der.subarray(offset, offset + sLen.length);
offset += sLen.length;
if (offset !== der.length) {
throw new Error('Extra bytes in DER signature');
}
return {
r: fixBufferLength(r, 32),
s: fixBufferLength(s, 32),
};
}
function readDerLength(buf: Buffer, offset: number): { length: number; bytesRead: number } {
const byte = buf[offset];
if (byte < 0x80) {
return { length: byte, bytesRead: 1 };
}
const bytesCount = byte & 0x7F;
if (bytesCount > 4) {
throw new Error('DER length too long');
}
let length = 0;
for (let i = 0; i < bytesCount; i++) {
length = (length << 8) | buf[offset + 1 + i];
}
return { length, bytesRead: 1 + bytesCount };
}
function fixBufferLength(buffer: Buffer, length: number): Buffer {
if (buffer.length > length) {
const start = buffer.length - length;
return buffer.subarray(start);
}
if (buffer.length < length) {
return Buffer.concat([Uint8Array.from(Buffer.alloc(length - buffer.length)), Uint8Array.from(buffer)]);
}
return buffer;
}
function generateDPOP({ uuid, method, url }: { uuid: string; method: string; url: string }): string {
// Generate ECDSA key pair
const { privateKey, publicKey } = crypto.generateKeyPairSync('ec', {
namedCurve: 'prime256v1',
});
// Create JWT payload
const payload = {
iat: Math.floor(Date.now() / 1000),
jti: uuid,
htu: url,
htm: method.toUpperCase(),
};
// Create JWT header
const header = publicKeyToHeader(publicKey);
// Prepare signing input
const headerB64 = strToBase64URL(JSON.stringify(header));
const payloadB64 = strToBase64URL(JSON.stringify(payload));
const signingInput = `${headerB64}.${payloadB64}`;
// Sign the input
const sign = crypto.createSign('SHA256');
sign.update(signingInput);
const derSignature = sign.sign(privateKey);
// Process signature
const { r, s } = derDecode(derSignature);
const signature = bytesToBase64URL(Buffer.concat([Uint8Array.from(r), Uint8Array.from(s)]));
return `${signingInput}.${signature}`;
}
const fetchFromMercari = async <T>(url: string, data: any, method: 'POST' | 'GET' = 'POST'): Promise<T> => {
const DPOP = generateDPOP({
uuid: uuidv4(),
method,
url,
});
const headers = new Headers({
DPOP,
'X-Platform': 'web',
'Accept-Encoding': 'gzip, deflate',
'Content-Type': 'application/json; charset=utf-8',
});
const options = {
method,
headers,
body: method === 'POST' ? JSON.stringify(data) : undefined,
query: method === 'GET' ? data : undefined,
};
try {
return await ofetch<T>(url, options);
} catch (error) {
throw new Error(`API request failed: ${error}`);
}
};
const pageToPageToken = (page: number): string => {
if (page === 0) {
return '';
}
return `v1:${page}`;
};
interface SearchOptions {
categoryId?: number[];
brandId?: number[];
priceMin?: number;
priceMax?: number;
itemConditionId?: number[];
excludeKeyword?: string;
itemTypes?: string[];
attributes?: Array<{
id: string;
values: string[];
}>;
}
const fetchSearchItems = async (sort: string, order: string, status: string[], keyword: string, options: SearchOptions = {}): Promise<SearchResponse> => {
const data = {
userId: `MERCARI_BOT_${uuidv4()}`,
pageSize: 120,
pageToken: pageToPageToken(0),
searchSessionId: uuidv4(),
indexRouting: 'INDEX_ROUTING_UNSPECIFIED',
thumbnailTypes: [],
searchCondition: {
keyword,
excludeKeyword: options.excludeKeyword || '',
sort,
order,
status: status || [],
sizeId: [],
categoryId: options.categoryId || [],
brandId: options.brandId || [],
sellerId: [],
priceMin: options.priceMin || 0,
priceMax: options.priceMax || 0,
itemConditionId: options.itemConditionId || [],
shippingPayerId: [],
shippingFromArea: [],
shippingMethod: [],
colorId: [],
hasCoupon: false,
attributes: options.attributes || [],
itemTypes: options.itemTypes || [],
skuIds: [],
shopIds: [],
excludeShippingMethodIds: [],
},
serviceFrom: 'suruga',
withItemBrand: true,
withItemSize: false,
withItemPromotions: true,
withItemSizes: true,
withShopname: false,
useDynamicAttribute: true,
withSuggestedItems: true,
withOfferPricePromotion: true,
withProductSuggest: true,
withParentProducts: false,
withProductArticles: true,
withSearchConditionId: true,
withAuction: true,
};
logger.debug(JSON.stringify(data));
return await fetchFromMercari<SearchResponse>(searchURL, data, 'POST');
};
const fetchItemDetail = (item_id: string, item_type: string, country_code?: string): Promise<ItemDetail | ShopItemDetail> => {
if (item_type === 'ITEM_TYPE_BEYOND') {
return fetchFromMercari<ShopItemDetail>(
shopItemInfoURL + item_id,
{
view: 'FULL',
imageType: 'JPEG',
},
'GET'
);
}
return fetchFromMercari<ItemDetail>(
itemInfoURL,
{
id: item_id,
country_code,
include_item_attributes: true,
include_product_page_component: true,
include_non_ui_item_attributes: true,
include_donation: true,
include_offer_like_coupon_display: true,
include_offer_coupon_display: true,
include_item_attributes_sections: true,
include_auction: false,
},
'GET'
);
};
const formatItemDetail = (detail: ItemDetail | ShopItemDetail): DataItem => {
if ((detail as ShopItemDetail).displayName) {
const shopItemDetail = detail as ShopItemDetail;
return {
title: shopItemDetail.displayName,
description: art(path.join(__dirname, 'templates/shopItem.art'), shopItemDetail),
pubDate: parseDate(shopItemDetail.createTime),
guid: shopItemDetail.name,
link: `${rootShopProductURL}${shopItemDetail.name}`,
image: shopItemDetail.thumbnail,
language: 'ja',
author: shopItemDetail.productDetail.shop.displayName,
updated: parseDate(shopItemDetail.updateTime),
};
}
const itemDetail = detail as ItemDetail;
return {
title: itemDetail.data.name,
description: art(path.join(__dirname, 'templates/item.art'), itemDetail),
pubDate: parseDate(itemDetail.data.created * 1000),
guid: itemDetail.data.id,
link: `${rootProductURL}${itemDetail.data.id}`,
image: itemDetail.data.thumbnails[0],
language: 'ja',
author: itemDetail.data.seller.name,
updated: parseDate(itemDetail.data.updated * 1000),
};
};
export { fetchSearchItems, fetchItemDetail, formatItemDetail, MercariSort, MercariOrder, MercariStatus };