웹 크롤링을 이용하여 이더리움 기반 NFT 프로젝트의 홀더 목록을 수집하는 방법에 대해 설명


1. 개요

웹 크롤링의 개념이나 의미 등에 대한 기본적인 것들은 설명하지 않고, 웹 크롤링을 이용해 특정 NFT를 소유하고 있는 홀더(지갑 주소) 목록을 수집하는 방법에 대해서 설명한다.

웹 크롤링 시 대부분 주로 Python 기반의 BeautifulSoup, Selenium 등을 사용한다.

나는 개발 시 Rust 언어를 이용하여 개발하였으며, Rust에서 Selenium과 같은 역할을 하는 thrityfour 라이브러리(crates.io - thirtyfour)를 사용하였다.

Selenium / thrityfour와 같은 라이브러리는 동적으로 로딩되는 페이지나 렌더링된 자바스크립트 등에 대해 정보를 수집할 수 있고, 자동화된 UI 테스트(버튼 클릭 등)를 지원한다.

웹 브라우저 및 드라이버를 직접 실행하여 정보를 가져오기 때문에 BeautifulSoup(서버와 통신을 통해 HTML 및 웹페이지를 받아오는 방식)에 비해 상대적으로 리소스를 많이 사용한다.

본 포스팅에서는 이더리움 체인의 대표적인 블루칩 프로젝트인 BAYC(Bored Ape Yatch Club)를 대상으로 웹 크롤링 및 홀더 목록 수집을 테스트한다.


2. Web Crawling을 위한 대상 웹 페이지 분석

이더리움 체인의 모든 NFT는 ERC721 토큰 컨트랙트로써 구현되어 있기 때문에, etherscan.io를 통해 조회가 가능하다.

BAYC 컨트랙트 주소 : 0xBC4CA0EdA7647A8aB7C2061c2E118A18a936f13D

image1

이더스캔으로 BAYC 컨트랙트 접근 시 다음과 같은 정보 확인 가능

  • URL : hxxps://etherscan[dot]io/address/0xbc4ca0eda7647a8ab7c2061c2e118a18a936f13d
    • Balance(이더리움 잔고)
    • Transactions(Normal 트랜잭션 목록)
    • Internal Txns(Internal 트랜잭션 목록)
    • ERC20 Token Txns(ERC20 토큰 트랜잭션 목록)
    • ERC721 Token Txns(ERC721 토큰 트랜잭션 목록)
    • Contract(컨트랙트 코드)

image2

  • URL : hxxps://etherscan[dot]io/token/0xbc4ca0eda7647a8ab7c2061c2e118a18a936f13d
    • Transfers(BAYC NFT 전송 내역)
    • Holders(홀더 목록)
    • Trades(거래 내역)

image3

우리는 BAYC Token 웹 페이지Holders 영역을 크롤링하여 NFT를 보유한 홀더(지갑 주소) 목록을 수집한다.

크롬 브라우저 개발자도구를 통해 렌더링된 페이지를 확인해보자.

홀더 목록은 다음과 같이 tokeholdersiframe 이라는 iframe의 html body에 속해있다.

image4

해당 프레임 내에서는 "//*[@id="maintable"]/div[3]/table/tbody" XPath 경로에 tr 목록이 위치한다. (원하는 지점의 XPath는 크롬 브라우저 개발자 도구를 이용해 추출 가능)

image5

한 페이지에 50개의 홀더 목록만 출력되기 때문에, 다음 페이지로 넘어가기 위해 “Next” 버튼을 클릭하는 이벤트를 발생시켜야 한다.

Next 버튼을 동적으로 클릭하기 위해 마찬가지로 XPath 경로를 추출해 이용한다.

image6


3. Web Crawling 코드 개발

브라우저 및 웹 드라이버 실행

let caps = DesiredCapabilities::chrome();
let driver = WebDriver::new("http://localhost:4444", caps).await?;

대상 페이지내 특정 iframe 전환

driver.get("https://etherscan.io/token/0xBC4CA0EdA7647A8aB7C2061c2E118A18a936f13D#balances").await?;
let document_iframe = driver.find_element(By::Id("tokeholdersiframe")).await?;
driver.switch_to().frame_element(&document_iframe).await?;

형식에 맞게 홀더 정보 수집 및 저장

let num_of_holders = 100;

for page in 1..=(num_of_holders/50){
    
    for _tr in 1..=50 {
          let mut holder_attr = HolderAttr::new();
          for _td in 1..=4 {
              let xpath = &format!("//*[@id=\"maintable\"]/div[3]/table/tbody/tr[{}]/td[{}]", _tr, _td)[..];
              let val = driver.find_element(By::XPath(xpath)).await?;

              match _td {
                    1 => {    // Rank
                        if let Ok(rank) = val.text().await?.parse::<u16>() {
                              holder_attr.rank = rank;
                        }
                    },
                    2 => {    // Address
                        let addr = val.text().await?;
                        if addr.starts_with("0x") {
                              holder_attr.addr = addr;
                        } else if addr.ends_with(".eth") {
                              holder_attr.ens_addr = addr;
                        } else {
                              holder_attr.tag_addr = addr;
                        }
                    },
                    3 => {    // Quantity
                        if let Ok(rank) = val.text().await?.replace(",","").parse::<u16>() {
                              holder_attr.quantity = rank;
                        }
                    },
                    4 => {    // Percentage
                        if let Ok(percentage) = val.text().await?.replace("%", "").parse::<f32>() {
                              holder_attr.percentage = percentage;
                        }
                    },
                    _ => {},
              }
          }

          println!("{:?}", holder_attr);
    }
    
    driver.find_element(By::LinkText("Next")).await?.click().await?;
}

다음 페이지 이동을 위한 클릭

driver.find_element(By::LinkText("Next")).await?.click().await?;

4. 결과

상위 100개의 지갑 주소를 수집하도록 실행 시, 다음과 같은 결과를 확인할 수 있다.

struct HolderAttr {
     rank: u16,
     tag_addr: String,
     ens_addr: String,
     addr: String,
     quantity: u16,
     percentage: f32,
}
  • rank : 전체 홀더 중 해당 지갑의 순위(홀딩 수량 기준)
  • tag_addr : 해당 지갑 주소의 별칭
  • ens_addr : 해당 지갑 주소의 ENS 주소
  • addr : 지갑 주소
  • quantity : 해당 지갑이 가진 NFT 수량
  • percentage : 해당 지갑이 가진 NFT 수량이 전체 발행량에서 차지하는 비율
Erc721HolderCrawl_Web2 % cargo run
    Finished dev [unoptimized + debuginfo] target(s) in 0.38s
     Running `target/debug/Erc721HolderCrawl_Web2`
HolderAttr { rank: 1, tag_addr: "", ens_addr: "", addr: "0xdbfd76af2157dc15ee4e57f3f942bb45ba84af24", quantity: 272, percentage: 2.72 }
HolderAttr { rank: 2, tag_addr: "", ens_addr: "jrnyclub.eth", addr: "", quantity: 107, percentage: 1.07 }
HolderAttr { rank: 3, tag_addr: "", ens_addr: "dingaling.eth", addr: "", quantity: 105, percentage: 1.05 }
HolderAttr { rank: 4, tag_addr: "", ens_addr: "", addr: "0xf896527c49b44aab3cf22ae356fa3af8e331f280", quantity: 74, percentage: 0.74 }
HolderAttr { rank: 5, tag_addr: "", ens_addr: "", addr: "0x98e711f31e49c2e50c1a290b6f2b1e493e43ea76", quantity: 71, percentage: 0.71 }
HolderAttr { rank: 6, tag_addr: "", ens_addr: "", addr: "0xd38a87d7b690323ef6883e887614502abcf9b1eb", quantity: 70, percentage: 0.7 }
HolderAttr { rank: 7, tag_addr: "j1mmy", ens_addr: "", addr: "", quantity: 57, percentage: 0.57 }
HolderAttr { rank: 8, tag_addr: "", ens_addr: "machibigbrother.eth", addr: "", quantity: 51, percentage: 0.51 }
HolderAttr { rank: 9, tag_addr: "", ens_addr: "bkr.eth", addr: "", quantity: 34, percentage: 0.34 }
HolderAttr { rank: 10, tag_addr: "BrandonKangFilms", ens_addr: "", addr: "", quantity: 33, percentage: 0.33 }
HolderAttr { rank: 11, tag_addr: "", ens_addr: "", addr: "0x7a9fe22691c811ea339d9b73150e6911a5343dca", quantity: 31, percentage: 0.31 }
HolderAttr { rank: 12, tag_addr: "", ens_addr: "", addr: "0xca1257ade6f4fa6c6834fdc42e030be6c0f5a813", quantity: 30, percentage: 0.3 }
HolderAttr { rank: 13, tag_addr: "", ens_addr: "franklinisbored.eth", addr: "", quantity: 28, percentage: 0.28 }
HolderAttr { rank: 14, tag_addr: "", ens_addr: "", addr: "0xd66f8eaf84b11654a19126a98a3f55b960846dd8", quantity: 28, percentage: 0.28 }
HolderAttr { rank: 15, tag_addr: "", ens_addr: "", addr: "0x1cfb8a2e4c2e849593882213b2468e369271dad2", quantity: 27, percentage: 0.27 }
...
HolderAttr { rank: 91, tag_addr: "", ens_addr: "", addr: "0x352e679327bd587432463bfd3382db3ee9ebc007", quantity: 8, percentage: 0.08 }
HolderAttr { rank: 92, tag_addr: "", ens_addr: "", addr: "0xa477803db91cb844d67742427e3573a9e8d19993", quantity: 7, percentage: 0.07 }
HolderAttr { rank: 93, tag_addr: "", ens_addr: "", addr: "0x2027bd5169cc0171194d77fa92eff0b0b76e7c65", quantity: 7, percentage: 0.07 }
HolderAttr { rank: 94, tag_addr: "", ens_addr: "cryptomid.eth", addr: "", quantity: 7, percentage: 0.07 }
HolderAttr { rank: 95, tag_addr: "", ens_addr: "", addr: "0x400e4f44d0ce889e1d09fc12815befb059d13535", quantity: 7, percentage: 0.07 }
HolderAttr { rank: 96, tag_addr: "The Bored Ape Comic: Deployer", ens_addr: "", addr: "", quantity: 7, percentage: 0.07 }
HolderAttr { rank: 97, tag_addr: "", ens_addr: "", addr: "0x9fd9d986b564fbaa45ee3fb6dc035a63bfc77f18", quantity: 7, percentage: 0.07 }
HolderAttr { rank: 98, tag_addr: "", ens_addr: "", addr: "0x6df304782b0866d2538c8fd2e46904a7f2ae321c", quantity: 7, percentage: 0.07 }
HolderAttr { rank: 99, tag_addr: "", ens_addr: "", addr: "0x1dec074b2f4281d9be5783a961d82f6ee67efab7", quantity: 7, percentage: 0.07 }
HolderAttr { rank: 100, tag_addr: "", ens_addr: "", addr: "0x68c4d9e03d7d902053c428ca2d74b612db7f583a", quantity: 7, percentage: 0.07 }

이더스캔에서는 전체 홀더 목록 중 상위 1000개만 제공하기 때문에, 이더스캔의 홀더 목록을 웹 크롤링하는 방식으로 모든 홀더 목록을 수집하는 것은 불가능하다.

다음 포스팅에서는 다른 방식을 이용하여 모든 홀더(지갑 주소)를 수집할 수 있는 방법에 대해 설명한다.