Opensea에서 생성된 컬렉션의 NFT 목록 가져오기

#2faced·2022년 3월 20일
5

대형 프로젝트들의 NFT들은 자체적으로 ERC721을 만들어서 사용하고 있습니다. 하지만 모든 아티스트들이 개발능력을 갖출 수 없기 때문에 오픈씨나 파운데이션을 통해서 자신의 작품을 올리곤 합니다. 모든 NFT는 이더리움 상에 기록되기 때문에 web3를 통해서 조회해볼 수 있습니다. 다만 오픈씨만큼은 조금은 다르게 돌아갑니다.

오픈씨는 자체적으로 OpenSea Shared Storefront라는 이름의 컬렉션을 만들고 그 안에 오픈씨 내부에서 생성한 NFT를 모두 관리하고 있습니다. 그래서 오픈씨 이외의 다른 거래소(예를들어 LooksRare)에서는 오픈씨에서 만들어진 컬렉션을 제대로 표시하지 못하는 문제가 발생합니다.

메이저한 프로젝트의 경우 제가 원하는 컬렉션의 NFT들을 가져오고 싶다면 web3를 통해서 해당 목록을 읽어오면 됩니다. 근데 오픈씨의 경우는 컬렉션을 서비스 자체적으로 관리하고 있기 때문에 일반적인 웹 방식을 사용하면 됩니다. 블록체인과 관련된 글로 기대하고 들어오셨다면 죄송합니다. 그냥 크롤러 관련된 내용입니다.

일단, 오픈씨에서는 공식적인 API를 제공합니다.

https://docs.opensea.io/reference/api-overview

다만 본 글에서는 크롤러 방식을 설명할 것입니다. API 문서에서 신청해보시면 알겠지만 신청절차가 꽤 까다롭고 심사 기간이 길기 때문입니다.

일단 작업은 Deno v1.19.1에서 진행하였습니다. (npm 설치가 필요없어 크롤러 만들때 최적의 환경이라고 생각합니다.)

개발자 도구를 켜고 오픈씨에서 사용하는 API를 살펴봅시다. 해당 페이지에 들어가면 요청하는 API가 있습니다. GraphQL을 사용하는군요.

다음과 같이 copy as fetch를 선택합니다. 그리고 소스코드를 붙여넣어봅시다.

const response = await const response = await fetch("https://api.opensea.io/graphql/", {
  "headers": {
    "accept": "*/*",
    "accept-language": "ko,en;q=0.9,en-US;q=0.8",
    "authorization":
      "JWT eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoiVlhObGNsUjVjR1U2TWpVd09URTBNakk9IiwidXNlcm5hbWUiOiIweDJmYWNlZCIsImFkZHJlc3MiOiIweDJmYWNlZGEyODM1MDU0OWM3YjRiYWMyYTg0MDRhNmQ4YTdkZWJlMmIiLCJpc3MiOiJPcGVuU2VhIiwiZXhwIjoxNjQ3ODcwOTA2LCJvcmlnSWF0IjoxNjQ3Nzg0NTA2fQ.04hE-83DZAOBMlO_Uz9GuOfCbJfG0KXOOXmfCodLg9k",
    "content-type": "application/json",
    "sec-ch-ua":
      '" Not A;Brand";v="99", "Chromium";v="99", "Google Chrome";v="99"',
    "sec-ch-ua-mobile": "?0",
    "sec-ch-ua-platform": '"macOS"',
    "sec-fetch-dest": "empty",
    "sec-fetch-mode": "cors",
    "sec-fetch-site": "same-site",
    "x-api-key": "...",
    "x-build-id": "...",
    "x-signed-query":
      "8305260a50fc51a99603924f36a860bef838705ee8db238c5de9feea263aa5a8",
  },
  "referrer": "https://opensea.io/",
  "referrerPolicy": "strict-origin",
  "body": ""/* 길어서 생략 */, 
  "method": "POST",
  "mode": "cors",
  "credentials": "include",
});

const body = await response.json()
console.log(body)

일단 돌려보고 정상적으로 결과가 오는지 확인합시다. 이런 작업을 할 때는, 최소한의 필요한 사항만 남기고 다 지우는게 중요합니다. GraphQL의 경우 method가 POST가 중요하고, content-type이 json이라는 점이 중요합니다. 이 두가지는 남겨두고 하나씩 다 지워나가면 x-signed-query라는 헤더가 없어지면 403 응답이 오는 것을 확인할 수 있습니다.

fetch("https://api.opensea.io/graphql/", {
  "method": "POST",
  "headers": {
    "content-type": "application/json",
    "x-signed-query":
      "8305260a50fc51a99603924f36a860bef838705ee8db238c5de9feea263aa5a8",
  },
  "body": ""/* 길어서 생략 */, 
});

대략 이름으로 추측해볼 수 있는 것은, 쿼리가 조작되었는지 (지금 제가 크롤링 하듯) 검증하는 사인이 포함된 것 같습니다. 웹앱의 경우 자바스크립트 코드 전체가 노출되어있기 때문에 시간이 걸리더라도 찾을 수 있습니다.

요령은.. 저 헤더 키 그대로 전체 소스코드에서 검색합니다.

검색결과가 2개가 나왔습니다. 첫번째 ceea8bb...5e0.js 파일이 실제 런타임에 동작하는 소스코드로 보입니다. 난독화 되어있습니다. 두번째는 일반적으로 나오지 않는데 소스맵 때문에 타입스크립트 원본 코드가 검색되었습니다. 타입스크립트 코드는 압축된 코드를 추측할 때 참고삼아 보시면 됩니다.

저희는 첫번째 파일(ceea8bb...5e0.js)을 열고, 크롬의 pretty print 기능을 통해 코드의 가독성을 높여봅시다.

필요한 부분의 코드를 복사해왔습니다. 다음 소스코드에서 주석을 참고하여 잘 쫓아가봅시다. 흐름에 따라 앞에 숫자를 표시해두었습니다.

, T = n("CrYB") // (7) T는 CrYB라는 매개변수로 불러오는데, 보통 이렇게 생긴것은 require("module") 일 가능성이 높습니다.
, O = function(e) { // (5) O라는 함수는 여기에 있습니다.
  var t = T[e]; //(6) T에서 불러옵니다.
  if (!t)
    throw new Error("Signature missing from generated file ".concat(e));
  return t
}
, P = function(e) { // (3) P라는 함수는 여기에 있습니다.
  var t, n = e.split("BATCH_REQUEST:");
  if (n.length > 1) {
    var r = n[1].split(":");
    t = JSON.stringify(r.map(O))
  } else
    t = O(e); // (4) t라는 값은 O라는 함수를 통해 생성됩니다. if 분기의 경우 Chrome Debugger의 breakpoint를 통해서 확인할 수 있습니다.
  return t
}

/* 중간생략 */
switch (t.prev = t.next) {
  case 0:
    return r = n.getID(),
      i = P(r), // (2) i변수는 P라는 함수를 통해 생성됩니다.
      n.fetchOpts.headers["x-signed-query"] = i, // (1) i 변수에 우리가 원하는 값이 들어갑니다.
      t.abrupt("return", e(n));
  case 4:
  case "end":
    return t.stop()
}

/* 중간생략 */   
CrYB: function(e) { // (8) CrYB를 검색하면 다음과 같은 내용을 확인할 수 있습니다.
  e.exports = JSON.parse('{"AccountCollectionsQuery": ...생략.. }')
},

우리가 찾던 사인키를 (8)에서 찾았습니다. GraphQL 쿼리의 사인키를 ID를 통해 상수로 미리 저장해두었네요. 크롤링에 필요한 모든 코드를 찾았습니다. 이제 위를 바탕으로 잘 정리하면 됩니다.

제가 정리한 코드는 Github에서 확인할 수 있습니다.

https://github.com/0x2faced/opensea-unofficial

간단한 샘플 코드를 작성해봅시다. :-) iTerm을 사용한다면 imgcat라이브러리를 통해 이미지를 터미널에서 미리 볼 수 있습니다.

import { printImage } from "https://deno.land/x/imgcat@v0.2.1/mod.ts";
import { query } from "https://raw.githubusercontent.com/0x2faced/opensea-unofficial/v0.0.1/opensea.ts";

let hasNext = true;
let cursor = null as string | null;
while (hasNext) {
  const { query: { search } } = await query("AssetSearchQuery", {
    cursor,
    collection: "ujin",
    collectionQuery: null,
    collectionSortBy: "SEVEN_DAY_VOLUME",
    collections: [
      "ujin",
    ],
    count: 32,
    resultModel: "ASSETS",
    showContextMenu: true,
    shouldShowQuantity: false,
    sortAscending: false,
    sortBy: "CREATED_DATE",
  });

  const assets = search.edges.map(({ node }) => {
    return {
      id: node.asset.id,
      name: node.asset.name,
      imageUrl: node.asset.imageUrl,
      tokenId: node.asset.tokenId,
      assetContract: {
        address: node.asset.assetContract.address,
        chain: node.asset.assetContract.chain,
      },
    };
  });

  for (const asset of assets) {
    const assetPageQueryResult = await query("AssetPageQuery", {
      tokenId: asset.tokenId,
      chain: asset.assetContract.chain,
      contractAddress: asset.assetContract.address,
    });

    const result = {
      imageUrl: assetPageQueryResult.nft.imageUrl,
      name: assetPageQueryResult.nft.name,
      description: assetPageQueryResult.nft.description,
      traits: assetPageQueryResult.nft.traits.edges.map(({ node }) => {
        return {
          type: node.traitType,
          value: node.value,
        };
      }),
      owner: assetPageQueryResult.nft.assetOwners.edges.map(({ node }) => {
        return {
          name: node.owner.displayName,
          publicName: node.owner.user.publicUsername,
          address: node.owner.address,
        };
      }),
    };

    const imageBuffer = await fetch(assetPageQueryResult.nft.imageUrl).then((
      res,
    ) => res.arrayBuffer());
    await printImage(new Uint8Array(imageBuffer));
    console.log(JSON.stringify(result, null, 2));
    await new Promise((resolve) => setTimeout(resolve, 3000));
  }

  hasNext = search.pageInfo.hasNextPage;
  cursor = search.pageInfo.endCursor;
}

돌려봅시다.

잘 수집됩니다. :-)

NFT관련 정보를 수집하는 과정에서 오픈씨의 독특한 정책때문에 고민하다가 비슷한 문제에 봉착하신 분들께 도움이 되었으면 합니다.

profile
컴맹, 블록체인에 관심 많습니다.

0개의 댓글