From cc29d870feea367f44a1e190965adaaf8434ff3f Mon Sep 17 00:00:00 2001 From: hanjano Date: Sun, 8 Oct 2023 18:27:45 +0200 Subject: [PATCH 1/3] feat: Fetch NFTs from OpenSea --- .../v2/api/data/opensea/collection-stats.json | 40 ++ .../test/v2/api/data/opensea/listings.json | 67 +++ .../test/v2/api/data/opensea/nfts-next.json | 18 + blockapi/test/v2/api/data/opensea/nfts.json | 19 + blockapi/test/v2/api/data/opensea/offers.json | 69 +++ blockapi/test/v2/api/fake_sleep_provider.py | 13 + blockapi/test/v2/api/nft/__init__.py | 0 blockapi/test/v2/api/nft/test_opensea.py | 197 +++++++++ blockapi/v2/api/nft/__init__.py | 1 + blockapi/v2/api/nft/opensea.py | 405 ++++++++++++++++++ blockapi/v2/base.py | 57 ++- blockapi/v2/coin_mapping.py | 9 + blockapi/v2/coins.py | 9 + blockapi/v2/models.py | 174 +++++++- 14 files changed, 1070 insertions(+), 8 deletions(-) create mode 100644 blockapi/test/v2/api/data/opensea/collection-stats.json create mode 100644 blockapi/test/v2/api/data/opensea/listings.json create mode 100644 blockapi/test/v2/api/data/opensea/nfts-next.json create mode 100644 blockapi/test/v2/api/data/opensea/nfts.json create mode 100644 blockapi/test/v2/api/data/opensea/offers.json create mode 100644 blockapi/test/v2/api/fake_sleep_provider.py create mode 100644 blockapi/test/v2/api/nft/__init__.py create mode 100644 blockapi/test/v2/api/nft/test_opensea.py create mode 100644 blockapi/v2/api/nft/__init__.py create mode 100644 blockapi/v2/api/nft/opensea.py create mode 100644 blockapi/v2/coin_mapping.py diff --git a/blockapi/test/v2/api/data/opensea/collection-stats.json b/blockapi/test/v2/api/data/opensea/collection-stats.json new file mode 100644 index 00000000..4743f35e --- /dev/null +++ b/blockapi/test/v2/api/data/opensea/collection-stats.json @@ -0,0 +1,40 @@ +{ + "total": { + "volume": 18.813597880000103, + "sales": 969, + "average_price": 0.0194154776883386, + "num_owners": 866, + "market_cap": 105.85218269230776, + "floor_price": 0.008, + "floor_price_symbol": "ETH" + }, + "intervals": [ + { + "interval": "one_day", + "volume": 0.14300000000000004, + "volume_diff": -0.23700000000000018, + "volume_change": -0.6236842274665833, + "sales": 28, + "sales_diff": 0.0, + "average_price": 0.005107142857142859 + }, + { + "interval": "one_week", + "volume": 0.0, + "volume_diff": 0.0, + "volume_change": 0.0, + "sales": 0, + "sales_diff": 0.0, + "average_price": 0.0 + }, + { + "interval": "one_month", + "volume": 0.0, + "volume_diff": 0.0, + "volume_change": 0.0, + "sales": 0, + "sales_diff": 0.0, + "average_price": 0.0 + } + ] +} \ No newline at end of file diff --git a/blockapi/test/v2/api/data/opensea/listings.json b/blockapi/test/v2/api/data/opensea/listings.json new file mode 100644 index 00000000..8cb06135 --- /dev/null +++ b/blockapi/test/v2/api/data/opensea/listings.json @@ -0,0 +1,67 @@ +{ + "listings": [ + { + "order_hash": "0x316f1cdcd4361385d10010abbab14a2a0e5d38cc777cf4ccb86cb046d6bb48df", + "chain": "ethereum", + "type": "basic", + "price": { + "current": { + "currency": "ETH", + "decimals": 18, + "value": "20000000000000000" + } + }, + "protocol_data": { + "parameters": { + "offerer": "0xe4fd714001f6cd80f5ffcfae4d827538d133dfa7", + "offer": [ + { + "itemType": 2, + "token": "0x8ACb0BC7F6c77E4E2aeF83EA928D5A6c2a0b7FCd", + "identifierOrCriteria": "90000001072", + "startAmount": "1", + "endAmount": "1" + } + ], + "consideration": [ + { + "itemType": 0, + "token": "0x0000000000000000000000000000000000000000", + "identifierOrCriteria": "0", + "startAmount": "17500000000000000", + "endAmount": "17500000000000000", + "recipient": "0xE4fD714001F6Cd80F5FfCfaE4D827538d133dfA7" + }, + { + "itemType": 0, + "token": "0x0000000000000000000000000000000000000000", + "identifierOrCriteria": "0", + "startAmount": "500000000000000", + "endAmount": "500000000000000", + "recipient": "0x0000a26b00c1F0DF003000390027140000fAa719" + }, + { + "itemType": 0, + "token": "0x0000000000000000000000000000000000000000", + "identifierOrCriteria": "0", + "startAmount": "2000000000000000", + "endAmount": "2000000000000000", + "recipient": "0x18D88467b7e1305BA4587DB69a45fE8416247113" + } + ], + "startTime": "1684766657", + "endTime": "1700318657", + "orderType": 0, + "zone": "0x004C00500000aD104D7DBd00e3ae0A5C00560C00", + "zoneHash": "0x0000000000000000000000000000000000000000000000000000000000000000", + "salt": "0x360c6ebe00000000000000000000000000000000000000006b19197378a23748", + "conduitKey": "0x0000007b02230091a7ed01230072f7006a004d60a8d4e71d599b8104250f0000", + "totalOriginalConsiderationItems": 3, + "counter": 0 + }, + "signature": null + }, + "protocol_address": "0x00000000000000adc04c56bf30ac9d3c0aaf14dc" + } + ] +} \ No newline at end of file diff --git a/blockapi/test/v2/api/data/opensea/nfts-next.json b/blockapi/test/v2/api/data/opensea/nfts-next.json new file mode 100644 index 00000000..91b3d76a --- /dev/null +++ b/blockapi/test/v2/api/data/opensea/nfts-next.json @@ -0,0 +1,18 @@ +{ + "nfts": [ + { + "identifier": "550885", + "collection": "uniswap-v3-positions", + "contract": "0xc36442b4a4522e871399cd717abdd847ab11fe88", + "token_standard": "erc721", + "name": "Uniswap - 1% - PRIME/WETH - 330.20<>780.29", + "description": "This NFT represents a liquidity position in a Uniswap V3 PRIME-WETH pool.", + "image_url": "https://openseauserdata.com/files/8b79194380e9a86f190713e19e78cef8.svg", + "metadata_url": "data:application/json;base64,eyJuYW1lIjoiVW5pc3dhcCAtIDElIC0gUFJJTUUvV0VUSCAtIDMzMC4yMDw+NzgwLjI5In0=", + "created_at": "", + "updated_at": "2023-08-15T13:56:39.759414", + "is_disabled": false, + "is_nsfw": false + } + ] +} \ No newline at end of file diff --git a/blockapi/test/v2/api/data/opensea/nfts.json b/blockapi/test/v2/api/data/opensea/nfts.json new file mode 100644 index 00000000..ca4b7a74 --- /dev/null +++ b/blockapi/test/v2/api/data/opensea/nfts.json @@ -0,0 +1,19 @@ +{ + "nfts": [ + { + "identifier": "150000000367", + "collection": "ever-fragments-of-civitas", + "contract": "0x8acb0bc7f6c77e4e2aef83ea928d5a6c2a0b7fcd", + "token_standard": "erc721", + "name": "Ever Fragment", + "description": "This is Ever Fragment", + "image_url": "https://i.seadn.io/gcs/files/13095497eff91a68417cc45a3ebd4468.png?w=500&auto=format", + "metadata_url": "https://meta.playcivitas.io/1/0x8acb0bc7f6c77e4e2aef83ea928d5a6c2a0b7fcd/150000000367.json", + "created_at": "", + "updated_at": "2023-09-13T13:48:32.891638", + "is_disabled": false, + "is_nsfw": false + } + ], + "next": "LXBrPTE0MDMyMTEyOTU=" +} \ No newline at end of file diff --git a/blockapi/test/v2/api/data/opensea/offers.json b/blockapi/test/v2/api/data/opensea/offers.json new file mode 100644 index 00000000..fb51df56 --- /dev/null +++ b/blockapi/test/v2/api/data/opensea/offers.json @@ -0,0 +1,69 @@ +{ + "offers": [ + { + "order_hash": "0x0d801370f4d01e9e3319dcff7a1067d093d3895a617ca4c9a6d2505a9cf4ad8f", + "chain": "ethereum", + "criteria": { + "collection": { + "slug": "ever-fragments-of-civitas" + }, + "contract": { + "address": "0x8acb0bc7f6c77e4e2aef83ea928d5a6c2a0b7fcd" + }, + "trait": null, + "encoded_token_ids": null + }, + "protocol_data": { + "parameters": { + "offerer": "0xc30992a53b3e91385ace2575963aa392edb3b931", + "offer": [ + { + "itemType": 1, + "token": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", + "identifierOrCriteria": "0", + "startAmount": "4000000000000000", + "endAmount": "4000000000000000" + } + ], + "consideration": [ + { + "itemType": 4, + "token": "0x8ACb0BC7F6c77E4E2aeF83EA928D5A6c2a0b7FCd", + "identifierOrCriteria": "0", + "startAmount": "8", + "endAmount": "8", + "recipient": "0xc30992a53b3e91385AcE2575963Aa392edb3B931" + }, + { + "itemType": 1, + "token": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", + "identifierOrCriteria": "0", + "startAmount": "20000000000000", + "endAmount": "20000000000000", + "recipient": "0x0000a26b00c1F0DF003000390027140000fAa719" + }, + { + "itemType": 1, + "token": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", + "identifierOrCriteria": "0", + "startAmount": "400000000000000", + "endAmount": "400000000000000", + "recipient": "0x18D88467b7e1305BA4587DB69a45fE8416247113" + } + ], + "startTime": "1696470613", + "endTime": "1696989013", + "orderType": 3, + "zone": "0x000000e7Ec00e7B300774b00001314B8610022b8", + "zoneHash": "0x0000000000000000000000000000000000000000000000000000000000000000", + "salt": "0x72db8c0b0000000000000000000000000000000000000000c07d8ab2cdf12360", + "conduitKey": "0x0000007b02230091a7ed01230072f7006a004d60a8d4e71d599b8104250f0000", + "totalOriginalConsiderationItems": 3, + "counter": 0 + }, + "signature": null + }, + "protocol_address": "0x00000000000000adc04c56bf30ac9d3c0aaf14dc" + } + ] +} \ No newline at end of file diff --git a/blockapi/test/v2/api/fake_sleep_provider.py b/blockapi/test/v2/api/fake_sleep_provider.py new file mode 100644 index 00000000..02f64922 --- /dev/null +++ b/blockapi/test/v2/api/fake_sleep_provider.py @@ -0,0 +1,13 @@ +from blockapi.v2.base import ISleepProvider + + +class FakeSleepProvider(ISleepProvider): + def __init__(self): + self._calls = [] + + def sleep(self, url: str, seconds: float): + self._calls.append((url, seconds)) + + @property + def calls(self): + return self._calls diff --git a/blockapi/test/v2/api/nft/__init__.py b/blockapi/test/v2/api/nft/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/blockapi/test/v2/api/nft/test_opensea.py b/blockapi/test/v2/api/nft/test_opensea.py new file mode 100644 index 00000000..bf1c3c48 --- /dev/null +++ b/blockapi/test/v2/api/nft/test_opensea.py @@ -0,0 +1,197 @@ +import datetime +from decimal import Decimal + +import pytest + +from blockapi.test.v2.api.conftest import read_file +from blockapi.test.v2.api.fake_sleep_provider import FakeSleepProvider +from blockapi.v2.api.nft import OpenSeaApi +from blockapi.v2.base import ApiException +from blockapi.v2.coins import COIN_ETH +from blockapi.v2.models import AssetType, Blockchain, NftOfferDirection + +nfts_test_address = '0x539C92186f7C6CC4CbF443F26eF84C595baBBcA1' + + +@pytest.fixture +def fake_sleep_provider(): + return FakeSleepProvider() + + +@pytest.fixture +def api(fake_sleep_provider): + return OpenSeaApi("some-key", Blockchain.ETHEREUM, fake_sleep_provider) + + +@pytest.fixture +def nfts_response(): + return read_file('data/opensea/nfts.json') + + +@pytest.fixture +def nfts_next_response(): + return read_file('data/opensea/nfts-next.json') + + +@pytest.fixture +def offers_response(): + return read_file('data/opensea/offers.json') + + +@pytest.fixture +def listings_response(): + return read_file('data/opensea/listings.json') + + +@pytest.fixture +def collection_stats_response(): + return read_file('data/opensea/collection-stats.json') + + +def test_fetch_ntfs( + requests_mock, api, nfts_response, nfts_next_response, fake_sleep_provider +): + requests_mock.get( + f'https://api.opensea.io/api/v2/chain/ethereum/account/{nfts_test_address}/nfts', + text=nfts_response, + ) + requests_mock.get( + f'https://api.opensea.io/api/v2/chain/ethereum/account/{nfts_test_address}/nfts?next=LXBrPTE0MDMyMTEyOTU=', + text=nfts_next_response, + ) + + nfts = api.fetch_nfts(nfts_test_address) + assert len(nfts.data) == 2 + assert len(fake_sleep_provider.calls) + assert fake_sleep_provider.calls[0] == ('https://api.opensea.io/', 0.25) + + +def test_parse_nfts(requests_mock, api, nfts_response, nfts_next_response): + requests_mock.get( + f'https://api.opensea.io/api/v2/chain/ethereum/account/{nfts_test_address}/nfts', + text=nfts_response, + ) + requests_mock.get( + f'https://api.opensea.io/api/v2/chain/ethereum/account/{nfts_test_address}/nfts?next=LXBrPTE0MDMyMTEyOTU=', + text=nfts_next_response, + ) + + nfts = api.fetch_nfts(nfts_test_address) + parsed = api.parse_nfts(nfts) + assert len(parsed.data) == 2 + data = parsed.data[1] + assert data.ident == '550885' + assert data.collection == 'uniswap-v3-positions' + assert data.contract == '0xc36442b4a4522e871399cd717abdd847ab11fe88' + assert data.standard == 'erc721' + assert data.name == 'Uniswap - 1% - PRIME/WETH - 330.20<>780.29' + assert ( + data.description + == 'This NFT represents a liquidity position in a Uniswap V3 PRIME-WETH pool.' + ) + assert ( + data.image_url + == 'https://openseauserdata.com/files/8b79194380e9a86f190713e19e78cef8.svg' + ) + assert ( + data.metadata_url + == 'data:application/json;base64,eyJuYW1lIjoiVW5pc3dhcCAtIDElIC0gUFJJTUUvV0VUSCAtIDMzMC4yMDw+NzgwLjI5In0=' + ) + assert not data.metadata + assert not data.created_time + assert data.updated_time == datetime.datetime(2023, 8, 15, 13, 56, 39, 759414) + assert not data.is_disabled + assert not data.is_nsfw + assert data.blockchain == Blockchain.ETHEREUM + assert data.asset_type == AssetType.AVAILABLE + + +def test_parse_offers(requests_mock, api, offers_response): + requests_mock.get( + f'https://api.opensea.io/api/v2/offers/collection/ever-fragments-of-civitas/all', + text=offers_response, + ) + + offers = api.fetch_offers('ever-fragments-of-civitas') + parsed = api.parse_offers(offers) + + assert not parsed.errors + data = parsed.data[0] + + assert data.direction == NftOfferDirection.OFFER + assert data.collection == 'ever-fragments-of-civitas' + assert data.contract == '0x8acb0bc7f6c77e4e2aef83ea928d5a6c2a0b7fcd' + assert data.start_time == datetime.datetime( + 2023, 10, 5, 1, 50, 13, tzinfo=datetime.timezone.utc + ) + assert data.end_time == datetime.datetime( + 2023, 10, 11, 1, 50, 13, tzinfo=datetime.timezone.utc + ) + + assert data.offerer == '0xc30992a53b3e91385ace2575963aa392edb3b931' + assert data.offer_coin.symbol == 'WETH' + assert not data.offer_ident + assert data.offer_contract == '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2' + assert data.offer_amount == Decimal('0.004') + + assert not data.pay_coin + assert data.pay_contract == '0x8acb0bc7f6c77e4e2aef83ea928d5a6c2a0b7fcd' + assert not data.pay_ident + assert data.pay_amount == Decimal('8') + + +def test_parse_listings(requests_mock, api, listings_response): + requests_mock.get( + f'https://api.opensea.io/api/v2/listings/collection/ever-fragments-of-civitas/all', + text=listings_response, + ) + + listings = api.fetch_listings('ever-fragments-of-civitas') + parsed = api.parse_listings(listings) + + assert not parsed.errors + data = parsed.data[0] + + assert data.direction == NftOfferDirection.LISTING + assert data.collection == 'ever-fragments-of-civitas' + # assert data.contract == '0x8acb0bc7f6c77e4e2aef83ea928d5a6c2a0b7fcd' + assert data.start_time == datetime.datetime( + 2023, 5, 22, 14, 44, 17, tzinfo=datetime.timezone.utc + ) + assert data.end_time == datetime.datetime( + 2023, 11, 18, 14, 44, 17, tzinfo=datetime.timezone.utc + ) + + assert data.offerer == '0xe4fd714001f6cd80f5ffcfae4d827538d133dfa7' + assert not data.offer_coin + assert data.offer_ident == '90000001072' + assert data.offer_amount == Decimal('1') + + assert data.pay_coin.symbol == 'ETH' + assert not data.pay_ident + assert data.pay_amount == Decimal('0.0175') + + +def test_parse_collection_stats(requests_mock, api, collection_stats_response): + requests_mock.get( + f'https://api.opensea.io/api/v2/collections/ever-fragments-of-civitas/stats', + text=collection_stats_response, + ) + + stats = api.fetch_collection_stats('ever-fragments-of-civitas') + parsed = api.parse_collection_stats(stats) + + assert not parsed.errors + data = parsed.data[0] + assert data.volume == Decimal('18.813597880000103') + assert data.sales_count == 969 + assert data.average_price == Decimal('0.0194154776883386') + assert data.owners_count == 866 + assert data.market_cap == Decimal('105.85218269230776') + assert data.floor_price == Decimal('0.008') + assert data.coin == COIN_ETH + + +def test_create_with_unsupported_blockchain(): + with pytest.raises(ApiException, match="Blockchain 'bitcoin' is not supported"): + OpenSeaApi('some-key', Blockchain.BITCOIN) diff --git a/blockapi/v2/api/nft/__init__.py b/blockapi/v2/api/nft/__init__.py new file mode 100644 index 00000000..6dfb14e1 --- /dev/null +++ b/blockapi/v2/api/nft/__init__.py @@ -0,0 +1 @@ +from blockapi.v2.api.nft.opensea import OpenSeaApi diff --git a/blockapi/v2/api/nft/opensea.py b/blockapi/v2/api/nft/opensea.py new file mode 100644 index 00000000..8af4a87c --- /dev/null +++ b/blockapi/v2/api/nft/opensea.py @@ -0,0 +1,405 @@ +import logging +from enum import Enum +from typing import Iterable, Optional + +from blockapi.v2.base import ( + ApiException, + BlockchainApi, + INftParser, + INftProvider, + ISleepProvider, + SleepProvider, +) +from blockapi.v2.coin_mapping import OPENSEA_COINS, OPENSEA_CONTRACTS +from blockapi.v2.coins import COIN_ETH +from blockapi.v2.models import ( + ApiOptions, + AssetType, + Blockchain, + Coin, + FetchResult, + NftCollectionStats, + NftOffer, + NftOfferDirection, + NftToken, + OfferItemType, + ParseResult, +) + +logger = logging.getLogger(__name__) + +OFFER_ITEM_TYPES = { + '0': OfferItemType.NATIVE, + '1': OfferItemType.ERC20, + '2': OfferItemType.ERC721, + '3': OfferItemType.ERC1155, + '4': OfferItemType.ERC721_WITH_CRITERIA, + '5': OfferItemType.ERC1155_WITH_CRITERIA, +} + + +class OpenSeaApi(BlockchainApi, INftProvider, INftParser): + """ + Ethereum + API docs: https://docs.opensea.io/reference/api-overview + Explorer: https://opensea.io/ + """ + + supported_blockchains = { + Blockchain.ETHEREUM: 'ethereum', + Blockchain.POLYGON: 'matic', + Blockchain.KLAYTN_CYPRESS: 'klaytn', + Blockchain.BINANCE_SMART_CHAIN: 'bsc', + Blockchain.ARBITRUM: 'arbitrum', + Blockchain.ARBITRUM_NOVA: 'arbitrum_nova', + Blockchain.AVALANCHE: 'avalanche', + Blockchain.OPTIMISM: 'optimism', + Blockchain.SOLANA: 'solana', + Blockchain.BASE: 'base', + Blockchain.ZORA: 'zora', + } + + coin = COIN_ETH + api_options = ApiOptions( + blockchain=Blockchain.ETHEREUM, + base_url='https://api.opensea.io/', + rate_limit=0.25, # 4 per second + ) + + supported_requests = { + 'get_nfts': 'api/v2/chain/{chain}/account/{address}/nfts', + 'get_offers': 'api/v2/offers/collection/{collection}/all', + 'get_listings': 'api/v2/listings/collection/{collection}/all', + 'get_collection_stats': 'api/v2/collections/{collection}/stats', + } + + json_parse_args = dict( + parse_int=lambda x: x, + parse_float=lambda x: x, + ) + + def __init__( + self, + api_key: str, + blockchain: Blockchain, + sleep_provider: ISleepProvider = None, + ): + super().__init__(api_key) + + self._blockchain = blockchain + self._opensea_chain = self.supported_blockchains.get(blockchain) + if not self._opensea_chain: + raise ApiException(f"Blockchain '{blockchain.value}' is not supported") + + self._headers = {'accept': 'application/json', 'x-api-key': api_key} + self._sleep_provider = sleep_provider or SleepProvider() + + def fetch_nfts(self, address: str) -> FetchResult: + logger.info(f'Fetch nfts from {address}') + return self._coallesce(self._yield_nfts(address)) + + def parse_nfts(self, fetch_result: FetchResult) -> ParseResult: + logger.info(f'Parse nfts') + + if not fetch_result: + return ParseResult() + + parsed = list(self._yield_parsed_nfts(fetch_result.data)) + return ParseResult(data=parsed, errors=fetch_result.errors) + + def fetch_offers(self, collection: str) -> FetchResult: + return self.get_data( + 'get_offers', + headers=self._headers, + collection=collection, + chain=self._opensea_chain, + extra=dict(collection=collection), + ) + + def parse_offers(self, fetch_result: FetchResult) -> ParseResult: + if not fetch_result or not fetch_result.data: + return ParseResult() + + return ParseResult( + data=list( + self._yield_offers( + NftOfferDirection.OFFER, + fetch_result.extra, + fetch_result.data.get('offers'), + ) + ) + ) + + def fetch_listings(self, collection: str) -> FetchResult: + return self.get_data( + 'get_listings', + headers=self._headers, + collection=collection, + chain=self._opensea_chain, + extra=dict(collection=collection), + ) + + def parse_listings(self, fetch_result: FetchResult) -> ParseResult: + if not fetch_result or not fetch_result.data: + return ParseResult() + + return ParseResult( + data=list( + self._yield_offers( + NftOfferDirection.LISTING, + fetch_result.extra, + fetch_result.data.get('listings'), + ) + ) + ) + + def fetch_collection_stats(self, collection: str) -> FetchResult: + return self.get_data( + 'get_collection_stats', + headers=self._headers, + collection=collection, + chain=self._opensea_chain, + ) + + def parse_collection_stats(self, fetch_result: FetchResult) -> ParseResult: + parsed, error = self._parse_collection(fetch_result.data.get('total')) + + return ParseResult( + data=[parsed] if parsed else None, errors=[error] if error else None + ) + + def _yield_nfts(self, address: str) -> Iterable[FetchResult]: + fetched = self._fetch_nfts_page(address) + cursor = fetched.data.get('next') + cursors = {cursor} + yield fetched + + while cursor: + try: + self._sleep_provider.sleep(self.base_url, self.api_options.rate_limit) + fetched = self._fetch_nfts_page(address, cursor) + cursor = fetched.data.get('next') + yield fetched + + if cursor in cursors: + logger.warning( + f'Detected duplicate cursor {cursor} while fetching NFTs for {address}' + ) + break + + cursors.add(cursor) + except Exception as e: + logger.error(f'Error fetching {address} NFTs from OpenSea') + logger.exception(e) + break + + def _fetch_nfts_page( + self, address: str, cursor: Optional[str] = None + ) -> FetchResult: + params = dict(next=cursor) if cursor else dict() + return self.get_data( + 'get_nfts', + headers=self._headers, + params=params, + address=address, + chain=self._opensea_chain, + ) + + def _yield_parsed_nfts(self, results): + if not results: + return + + for data in results: + items = data.get('nfts') + + if not items: + continue + + for item in items: + yield NftToken.from_api( + ident=item.get('identifier'), + collection=item.get('collection'), + contract=item.get('contract'), + standard=item.get('token_standard'), + name=item.get('name'), + description=item.get('description'), + image_url=item.get('image_url'), + metadata_url=item.get('metadata_url'), + created_time=item.get('created_at'), + updated_time=item.get('updated_at'), + is_disabled=item.get('is_disabled'), + is_nsfw=item.get('is_nsfw'), + blockchain=self._blockchain, + asset_type=AssetType.AVAILABLE, + ) + + @staticmethod + def _parse_collection( + data: dict, + ) -> tuple[Optional[NftCollectionStats], Optional[str]]: + symbol = data.get('floor_price_symbol') + coin = OPENSEA_COINS.get(symbol) + if not coin: + if not symbol: + return None, None + + return None, f'There is no mapping for opensea symbol {symbol}' + + stats = NftCollectionStats.from_api( + coin=coin, + volume=data.get('volume'), + market_cap=data.get('market_cap'), + floor_price=data.get('floor_price'), + average_price=data.get('average_price'), + sales_count=data.get('sales'), + owners_count=data.get('num_owners'), + ) + + return stats, None + + def _yield_offers( + self, direction: NftOfferDirection, extra: dict, items: list + ) -> Iterable[NftOffer]: + collection = extra.get('collection') + contract = extra.get('contract') + + for item in items: + parsed = self._parse_offer(direction, collection, contract, item) + if parsed: + yield parsed + + def _parse_offer( + self, + direction: NftOfferDirection, + collection: Optional[str], + contract: Optional[str], + item: dict, + ) -> Optional[NftOffer]: + params = self._get_offer_params(item) + if not params: + return None + + if not contract: + contract = self._parse_contract(item) + + offerer = params.get('offerer') + if not offerer: + return None + + offerer = offerer.lower() + + offer = params.get('offer') + if not offer: + return None + + if len(offer) > 1: + logger.warning(f'Multiple {direction} items: {collection} - {contract}') + + ( + offer_coin, + offer_contract, + offer_ident, + offer_amount, + _, + ) = self._parse_offer_item(offer[0]) + pay = self._parse_consideration(params.get('consideration'), offerer) + pay_coin, pay_contract, pay_ident, pay_amount = next( + pay, (None, None, None, None) + ) + + return NftOffer.from_api( + direction=direction, + collection=collection, + contract=contract, + offerer=offerer, + start_time=params.get('startTime'), + end_time=params.get('endTime'), + offer_coin=offer_coin, + offer_contract=offer_contract, + offer_ident=offer_ident, + offer_amount=offer_amount, + pay_coin=pay_coin, + pay_contract=pay_contract, + pay_ident=pay_ident, + pay_amount=pay_amount, + ) + + @staticmethod + def _parse_contract(item) -> Optional[str]: + criteria = item.get('criteria') + if not criteria: + return None + + contract = criteria.get('contract') + if not contract: + return None + + if result := contract.get('address'): + return result.lower() + + @staticmethod + def _get_offer_params(item): + if proto := item.get('protocol_data'): + return proto.get('parameters') + + def _parse_consideration( + self, items, recipient + ) -> Iterable[tuple[Optional[Coin], Optional[str], Optional[str], Optional[str]]]: + if not items: + return + + for item in items: + coin, contract, ident, amount, rec = self._parse_offer_item(item) + if rec == recipient: + yield coin, contract, ident, amount + + def _parse_offer_item( + self, param + ) -> tuple[Optional[Coin], Optional[str], Optional[str], str, Optional[str]]: + type_ = self._get_type(param.get('itemType')) + token = param.get('token') + amount = param.get('startAmount') + recipient = param.get('recipient') + if recipient: + recipient = recipient.lower() + + if type_ in [OfferItemType.NATIVE, OfferItemType.ERC20] and token: + coin = OPENSEA_CONTRACTS.get((token.lower(), self._blockchain)) + return coin, token, None, amount, recipient + + ident = param.get('identifierOrCriteria') + if ident == '0': + ident = None + + return None, token, ident, amount, recipient + + @staticmethod + def _get_type(item_type) -> Optional[OfferItemType]: + if not item_type: + return None + + return OFFER_ITEM_TYPES.get(item_type) + + def _coallesce(self, fetch_results: Iterable[FetchResult]): + data = [] + errors = [] + last = None + for item in fetch_results: + last = item + if item.data: + data.append(item.data) + + if item.errors: + errors.extend(item.errors) + + if not last: + return FetchResult(data=data, errors=errors) + + return FetchResult( + status_code=last.status_code, + headers=last.headers, + extra=last.extra, + time=last.time, + data=data, + errors=errors, + ) diff --git a/blockapi/v2/base.py b/blockapi/v2/base.py index d02cc5e4..04ca4b26 100644 --- a/blockapi/v2/base.py +++ b/blockapi/v2/base.py @@ -1,3 +1,4 @@ +import time from abc import ABC from datetime import datetime, timedelta from typing import Dict, List, Optional, Tuple, Type, Union @@ -24,12 +25,16 @@ class CustomizableBlockchainApi(ABC): e.g. proxy, testnet, RPC services, alternative sources """ + base_url: str + coin: Coin = NotImplemented api_options: ApiOptions = NotImplemented # {request_method: request_url} supported_requests: Dict[str, str] = {} + json_parse_args = dict() + def __init__(self, base_url: Optional[str] = None, api_key: Optional[str] = None): self.api_key = api_key self._session = Session() @@ -46,29 +51,31 @@ def __del__(self): def get( self, request_method: str, - headers=None, + headers: Optional[dict[str, any]] = None, + params: Optional[dict[str, any]] = None, **req_args, ) -> Dict: """ Call specific request method with params and return raw response. """ - response = self._get_response(request_method, headers, req_args) + response = self._get_response(request_method, headers, params, req_args) return self._check_and_get_from_response(response) def get_data( self, request_method: str, headers: Optional[dict[str, any]] = None, + params: Optional[dict[str, any]] = None, extra: Optional[dict] = None, **req_args, ) -> FetchResult: - response = self._get_response(request_method, headers, req_args) + response = self._get_response(request_method, headers, params, req_args) time = self._get_response_time(response.headers) if response.status_code == 200: return FetchResult( status_code=response.status_code, headers=self._get_headers_dict(response.headers), - data=response.json(), + data=response.json(**self.json_parse_args), extra=extra, time=time, ) @@ -81,9 +88,9 @@ def get_data( time=time, ) - def _get_response(self, request_method, headers, req_args): + def _get_response(self, request_method, headers, params, req_args): url = self._build_request_url(request_method, **req_args) - response = self._session.get(url, headers=headers) + response = self._session.get(url, headers=headers, params=params) return response def _build_request_url(self, request_method: str, **req_args): @@ -185,6 +192,44 @@ def get_portfolio(self, address: str) -> List[Pool]: raise NotImplementedError +class ISleepProvider(ABC): + def sleep(self, url: str, seconds: float) -> None: + raise NotImplementedError + + +class SleepProvider(ISleepProvider): + def sleep(self, url: str, seconds: float): + time.sleep(seconds) + + +class INftProvider(ABC): + def fetch_nfts(self, address: str) -> FetchResult: + raise NotImplementedError + + def fetch_collection_stats(self, collection: str) -> FetchResult: + raise NotImplementedError + + def fetch_offers(self, collection: str) -> FetchResult: + raise NotImplementedError + + def fetch_listings(self, collection: str) -> FetchResult: + raise NotImplementedError + + +class INftParser(ABC): + def parse_nfts(self, data: FetchResult) -> ParseResult: + raise NotImplementedError + + def parse_collections(self, data: FetchResult) -> ParseResult: + raise NotImplementedError + + def parse_offers(self, data: FetchResult) -> ParseResult: + raise NotImplementedError + + def parse_listings(self, data: FetchResult) -> ParseResult: + raise NotImplementedError + + class ApiException(Exception): pass diff --git a/blockapi/v2/coin_mapping.py b/blockapi/v2/coin_mapping.py new file mode 100644 index 00000000..15446226 --- /dev/null +++ b/blockapi/v2/coin_mapping.py @@ -0,0 +1,9 @@ +from blockapi.v2.coins import COIN_ETH, COIN_WETH +from blockapi.v2.models import Blockchain, Coin + +OPENSEA_COINS: dict[str, Coin] = {'ETH': COIN_ETH} + +OPENSEA_CONTRACTS = { + ('0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2', Blockchain.ETHEREUM): COIN_WETH, + ('0x0000000000000000000000000000000000000000', Blockchain.ETHEREUM): COIN_ETH, +} diff --git a/blockapi/v2/coins.py b/blockapi/v2/coins.py index c9905ff0..7d9f76b5 100644 --- a/blockapi/v2/coins.py +++ b/blockapi/v2/coins.py @@ -9,6 +9,15 @@ info=CoinInfo(coingecko_id=CoingeckoId.ETHEREUM), ) +COIN_WETH = Coin( + symbol='WETH', + name='Wrapped Ethereum', + decimals=18, + blockchain=Blockchain.ETHEREUM, + address='0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2', + info=CoinInfo(coingecko_id=CoingeckoId.WETH), +) + COIN_SOL = Coin( symbol='SOL', diff --git a/blockapi/v2/models.py b/blockapi/v2/models.py index 293cbf50..b827f183 100644 --- a/blockapi/v2/models.py +++ b/blockapi/v2/models.py @@ -2,7 +2,7 @@ from datetime import datetime from decimal import Decimal from enum import Enum -from typing import Dict, List, Optional, Union +from typing import Dict, List, Literal, Optional, Union import attr @@ -303,6 +303,20 @@ class CoingeckoId(str, Enum): XDAI = 'xdai' +class NftOfferDirection(str, Enum): + OFFER = 'offer' + LISTING = 'listing' + + +class OfferItemType(str, Enum): + NATIVE = ('native',) + ERC20 = ('erc-20',) + ERC721 = ('erc-721',) + ERC1155 = ('erc-1155',) + ERC721_WITH_CRITERIA = ('erc-721-limited',) + ERC1155_WITH_CRITERIA = 'erc-1155-limited' + + @attr.s(auto_attribs=True, slots=True) class ApiOptions: blockchain: Blockchain @@ -513,6 +527,159 @@ def _add_raw(self, other): return {"merged": [self.raw, other.raw]} +@attr.s(auto_attribs=True, slots=True, frozen=True) +class NftToken: + ident: str + collection: str + contract: str + standard: str + name: str + description: Optional[str] + image_url: str + metadata_url: Optional[str] + metadata: Optional[dict] + created_time: Optional[datetime] + updated_time: Optional[datetime] + is_disabled: bool + is_nsfw: bool + asset_type: AssetType + blockchain: Blockchain + + @classmethod + def from_api( + cls, + *, + ident: str, + collection: str, + contract: str, + standard: Literal['erc721', 'erc1155'], + name: str, + description: str, + image_url: str, + metadata_url: str, + created_time: Optional[Union[str, datetime]], + updated_time: Optional[Union[str, datetime]], + is_disabled: bool, + is_nsfw: bool, + blockchain: Blockchain, + asset_type: AssetType = AssetType.AVAILABLE, + ) -> 'NftToken': + return cls( + ident=ident, + collection=collection, + contract=contract, + standard=standard, + name=name, + description=description, + image_url=image_url, + metadata_url=metadata_url, + metadata=None, + created_time=parse_dt(created_time) + if created_time and created_time.strip() + else None, + updated_time=parse_dt(updated_time) + if updated_time and updated_time.strip() + else None, + is_disabled=is_disabled, + is_nsfw=is_nsfw, + blockchain=blockchain, + asset_type=asset_type, + ) + + +@attr.s(auto_attribs=True, slots=True, frozen=True) +class NftOffer: + direction: NftOfferDirection + collection: str + contract: str + offerer: str + start_time: datetime + end_time: datetime + + offer_coin: Optional[Coin] + offer_contract: Optional[str] + offer_ident: Optional[str] + offer_amount: Decimal + + pay_coin: Optional[Coin] + pay_contract: Optional[str] + pay_ident: Optional[str] + pay_amount: Decimal + + @classmethod + def from_api( + cls, + *, + direction: NftOfferDirection, + collection: str, + contract: str, + offerer: str, + start_time: str, + end_time: str, + offer_coin: Optional[Coin], + offer_contract: Optional[str], + offer_ident: Optional[str], + offer_amount: str, + pay_coin: Optional[Coin], + pay_contract: Optional[str], + pay_ident: Optional[str], + pay_amount: str, + ) -> 'NftOffer': + return NftOffer( + direction=direction, + collection=collection, + contract=contract, + offerer=offerer, + start_time=parse_dt(int(start_time)) if start_time else None, + end_time=parse_dt(int(end_time)) if end_time else None, + offer_coin=offer_coin, + offer_contract=offer_contract.lower() if offer_contract else None, + offer_ident=offer_ident, + offer_amount=raw_to_decimals(offer_amount, offer_coin.decimals) + if offer_coin + else to_decimal(offer_amount), + pay_coin=pay_coin, + pay_contract=pay_contract.lower() if pay_contract else None, + pay_ident=pay_ident, + pay_amount=raw_to_decimals(pay_amount, pay_coin.decimals) + if pay_coin + else to_decimal(pay_amount), + ) + + +@attr.s(auto_attribs=True, slots=True, frozen=True) +class NftCollectionStats: + coin: Coin + volume: Decimal + market_cap: Decimal + floor_price: Decimal + average_price: Decimal + sales_count: int + owners_count: int + + @classmethod + def from_api( + cls, + *, + coin: Coin, + volume: str, + market_cap: str, + floor_price: str, + average_price: str, + sales_count: str, + owners_count: str, + ): + return NftCollectionStats( + coin=coin, + volume=Decimal(volume), + market_cap=Decimal(market_cap), + floor_price=Decimal(floor_price), + average_price=Decimal(average_price), + sales_count=int(sales_count) if sales_count else 0, + owners_count=int(owners_count) if owners_count else 0, + ) + + @attr.s(auto_attribs=True, slots=True, frozen=True) class OperationItem: amount: Decimal @@ -635,6 +802,9 @@ def json(self): @attr.s(auto_attribs=True, slots=True, frozen=True) class ParseResult: - data: Optional[list[Union[BalanceItem, Pool]]] = None + data: Optional[ + list[Union[BalanceItem, Pool, NftToken, NftCollectionStats, NftOffer]] + ] = None warnings: Optional[list[Union[str, dict]]] = None errors: Optional[list[Union[str, dict]]] = None + time: Optional[datetime] = None From 3ce8199568af2562f0446cd8bebf86151864b2c4 Mon Sep 17 00:00:00 2001 From: hanjano Date: Wed, 18 Oct 2023 09:38:51 +0200 Subject: [PATCH 2/3] fix: Fetching collections --- .../test/v2/api/data/opensea/collection.json | 43 ++++++++ blockapi/test/v2/api/nft/test_opensea.py | 46 ++++++-- blockapi/v2/api/nft/opensea.py | 100 ++++++++++++++---- blockapi/v2/models.py | 100 ++++++++++++++---- 4 files changed, 241 insertions(+), 48 deletions(-) create mode 100644 blockapi/test/v2/api/data/opensea/collection.json diff --git a/blockapi/test/v2/api/data/opensea/collection.json b/blockapi/test/v2/api/data/opensea/collection.json new file mode 100644 index 00000000..68efd278 --- /dev/null +++ b/blockapi/test/v2/api/data/opensea/collection.json @@ -0,0 +1,43 @@ +{ + "collection": "ever-fragments-of-civitas", + "name": "Ever Fragments of Civitas", + "description": "The [Civitas](https://playcivitas.io/) **Chosen** [NFT collection](https://opensea.io/collection/the-chosen-of-civitas) generate monthly [Ever Fragments](https://docs.playcivitas.io/whitepaper/ever-fragments-and-crystals), that are a key component of the land generation process in Civitas, the world's first community-owned strategy MMO. They combine to form Ever Crystals, which are used to create unique and highly productive land tiles rendered procedurally through an algorithmic art generation pipeline.", + "image_url": "https://openseauserdata.com/files/f784cefee7f7da9fe4c75ec04279b8b0.png", + "banner_image_url": "https://openseauserdata.com/files/68cc3d74e23c515b5ecaf4785dab99ed.png", + "owner": "0x490d4d1452c953290925e96e6a68b247d94b6f99", + "safelist_status": "not_requested", + "category": "gaming", + "is_disabled": false, + "is_nsfw": false, + "trait_offers_enabled": false, + "opensea_url": "https://opensea.io/collection/ever-fragments-of-civitas", + "project_url": "https://playcivitas.io", + "wiki_url": "", + "discord_url": "", + "telegram_url": "https://t.me/civitasofficial", + "twitter_username": "", + "instagram_username": "", + "contracts": [ + { + "address": "0x8acb0bc7f6c77e4e2aef83ea928d5a6c2a0b7fcd", + "chain": "ethereum" + } + ], + "editors": [ + "0x490d4d1452c953290925e96e6a68b247d94b6f99", + "0x18d88467b7e1305ba4587db69a45fe8416247113", + "0xa5067aabb149076008fc798378ec0b77933f9cd5" + ], + "fees": [ + { + "fee": 2.5, + "recipient": "0x0000a26b00c1f0df003000390027140000faa719", + "required": true + }, + { + "fee": 10.0, + "recipient": "0x18d88467b7e1305ba4587db69a45fe8416247113", + "required": true + } + ] +} \ No newline at end of file diff --git a/blockapi/test/v2/api/nft/test_opensea.py b/blockapi/test/v2/api/nft/test_opensea.py index bf1c3c48..fb883d36 100644 --- a/blockapi/test/v2/api/nft/test_opensea.py +++ b/blockapi/test/v2/api/nft/test_opensea.py @@ -48,6 +48,11 @@ def collection_stats_response(): return read_file('data/opensea/collection-stats.json') +@pytest.fixture +def collection_response(): + return read_file('data/opensea/collection.json') + + def test_fetch_ntfs( requests_mock, api, nfts_response, nfts_next_response, fake_sleep_provider ): @@ -98,7 +103,6 @@ def test_parse_nfts(requests_mock, api, nfts_response, nfts_next_response): == 'data:application/json;base64,eyJuYW1lIjoiVW5pc3dhcCAtIDElIC0gUFJJTUUvV0VUSCAtIDMzMC4yMDw+NzgwLjI5In0=' ) assert not data.metadata - assert not data.created_time assert data.updated_time == datetime.datetime(2023, 8, 15, 13, 56, 39, 759414) assert not data.is_disabled assert not data.is_nsfw @@ -172,24 +176,44 @@ def test_parse_listings(requests_mock, api, listings_response): assert data.pay_amount == Decimal('0.0175') -def test_parse_collection_stats(requests_mock, api, collection_stats_response): +def test_parse_collection( + requests_mock, api, collection_response, collection_stats_response +): + requests_mock.get( + f'https://api.opensea.io/api/v2/collections/ever-fragments-of-civitas', + text=collection_response, + ) + requests_mock.get( f'https://api.opensea.io/api/v2/collections/ever-fragments-of-civitas/stats', text=collection_stats_response, ) - stats = api.fetch_collection_stats('ever-fragments-of-civitas') - parsed = api.parse_collection_stats(stats) + collection = api.fetch_collection('ever-fragments-of-civitas') + parsed = api.parse_collection(collection) assert not parsed.errors data = parsed.data[0] - assert data.volume == Decimal('18.813597880000103') - assert data.sales_count == 969 - assert data.average_price == Decimal('0.0194154776883386') - assert data.owners_count == 866 - assert data.market_cap == Decimal('105.85218269230776') - assert data.floor_price == Decimal('0.008') - assert data.coin == COIN_ETH + assert data.name == 'Ever Fragments of Civitas' + assert data.ident == 'ever-fragments-of-civitas' + assert ( + data.image + == 'https://openseauserdata.com/files/f784cefee7f7da9fe4c75ec04279b8b0.png' + ) + assert not data.is_disabled + assert not data.is_nsfw + assert data.total_stats + assert data.day_stats + assert not data.week_stats + assert not data.month_stats + + assert data.total_stats.volume == Decimal('18.813597880000103') + assert data.total_stats.sales_count == 969 + assert data.total_stats.average_price == Decimal('0.0194154776883386') + assert data.total_stats.owners_count == 866 + assert data.total_stats.market_cap == Decimal('105.85218269230776') + assert data.total_stats.floor_price == Decimal('0.008') + assert data.total_stats.coin == COIN_ETH def test_create_with_unsupported_blockchain(): diff --git a/blockapi/v2/api/nft/opensea.py b/blockapi/v2/api/nft/opensea.py index 8af4a87c..76cd69f5 100644 --- a/blockapi/v2/api/nft/opensea.py +++ b/blockapi/v2/api/nft/opensea.py @@ -2,6 +2,8 @@ from enum import Enum from typing import Iterable, Optional +from _decimal import Decimal + from blockapi.v2.base import ( ApiException, BlockchainApi, @@ -18,7 +20,9 @@ Blockchain, Coin, FetchResult, - NftCollectionStats, + NftCollection, + NftCollectionIntervalStats, + NftCollectionTotalStats, NftOffer, NftOfferDirection, NftToken, @@ -70,6 +74,7 @@ class OpenSeaApi(BlockchainApi, INftProvider, INftParser): 'get_nfts': 'api/v2/chain/{chain}/account/{address}/nfts', 'get_offers': 'api/v2/offers/collection/{collection}/all', 'get_listings': 'api/v2/listings/collection/{collection}/all', + 'get_collection': 'api/v2/collections/{collection}', 'get_collection_stats': 'api/v2/collections/{collection}/stats', } @@ -153,19 +158,35 @@ def parse_listings(self, fetch_result: FetchResult) -> ParseResult: ) ) - def fetch_collection_stats(self, collection: str) -> FetchResult: - return self.get_data( + def fetch_collection(self, collection: str) -> FetchResult: + stats = self.get_data( 'get_collection_stats', headers=self._headers, collection=collection, chain=self._opensea_chain, ) - def parse_collection_stats(self, fetch_result: FetchResult) -> ParseResult: - parsed, error = self._parse_collection(fetch_result.data.get('total')) + return self.get_data( + 'get_collection', + headers=self._headers, + collection=collection, + chain=self._opensea_chain, + extra=dict(stats=stats.data, stats_errors=stats.errors), + ) + + def parse_collection(self, fetch_result: FetchResult) -> ParseResult: + parsed, error = self._parse_collection( + fetch_result.data, fetch_result.extra.get('stats') + ) + errors = [] + if error: + errors.append(error) + + if stats_errors := fetch_result.extra['stats_errors']: + errors.append(stats_errors) return ParseResult( - data=[parsed] if parsed else None, errors=[error] if error else None + data=[parsed] if parsed else None, errors=errors if errors else None ) def _yield_nfts(self, address: str) -> Iterable[FetchResult]: @@ -225,7 +246,6 @@ def _yield_parsed_nfts(self, results): description=item.get('description'), image_url=item.get('image_url'), metadata_url=item.get('metadata_url'), - created_time=item.get('created_at'), updated_time=item.get('updated_at'), is_disabled=item.get('is_disabled'), is_nsfw=item.get('is_nsfw'), @@ -233,11 +253,11 @@ def _yield_parsed_nfts(self, results): asset_type=AssetType.AVAILABLE, ) - @staticmethod def _parse_collection( - data: dict, - ) -> tuple[Optional[NftCollectionStats], Optional[str]]: - symbol = data.get('floor_price_symbol') + self, data: dict, stat_data: dict + ) -> tuple[Optional[NftCollection], Optional[str]]: + total = stat_data.get('total') + symbol = total.get('floor_price_symbol') coin = OPENSEA_COINS.get(symbol) if not coin: if not symbol: @@ -245,17 +265,59 @@ def _parse_collection( return None, f'There is no mapping for opensea symbol {symbol}' - stats = NftCollectionStats.from_api( + total_stats = NftCollectionTotalStats.from_api( + volume=total.get('volume'), + sales_count=total.get('sales'), + owners_count=total.get('num_owners'), + market_cap=total.get('market_cap'), + floor_price=total.get('floor_price'), + average_price=total.get('average_price'), coin=coin, - volume=data.get('volume'), - market_cap=data.get('market_cap'), - floor_price=data.get('floor_price'), - average_price=data.get('average_price'), - sales_count=data.get('sales'), - owners_count=data.get('num_owners'), ) - return stats, None + intervals = stat_data.get('intervals') + day_stats = self._parse_collection_stats(intervals, 'one_day') + week_stats = self._parse_collection_stats(intervals, 'one_week') + month_stats = self._parse_collection_stats(intervals, 'one_month') + + collection = NftCollection.from_api( + ident=data.get('collection'), + name=data.get('name'), + image=data.get('image_url'), + is_disabled=data.get('is_disabled'), + is_nsfw=data.get('is_nsfw'), + total_stats=total_stats, + day_stats=day_stats, + week_stats=week_stats, + month_stats=month_stats, + ) + + return collection, None + + @staticmethod + def _parse_collection_stats( + intervals, interval + ) -> Optional[NftCollectionIntervalStats]: + if not intervals: + return None + + for it in intervals: + if it.get('interval') != interval: + continue + + st = NftCollectionIntervalStats.from_api( + volume=it.get('volume'), + volume_diff=it.get('volume_diff'), + volume_percent_change=it.get('volume_change'), + sales_count=it.get('sales'), + sales_diff=it.get('sales_diff'), + average_price=it.get('average_price'), + ) + + if st.volume == 0 and st.sales_count == 0: + return + + return st def _yield_offers( self, direction: NftOfferDirection, extra: dict, items: list diff --git a/blockapi/v2/models.py b/blockapi/v2/models.py index b827f183..ce4fa78e 100644 --- a/blockapi/v2/models.py +++ b/blockapi/v2/models.py @@ -538,7 +538,6 @@ class NftToken: image_url: str metadata_url: Optional[str] metadata: Optional[dict] - created_time: Optional[datetime] updated_time: Optional[datetime] is_disabled: bool is_nsfw: bool @@ -557,7 +556,6 @@ def from_api( description: str, image_url: str, metadata_url: str, - created_time: Optional[Union[str, datetime]], updated_time: Optional[Union[str, datetime]], is_disabled: bool, is_nsfw: bool, @@ -574,9 +572,6 @@ def from_api( image_url=image_url, metadata_url=metadata_url, metadata=None, - created_time=parse_dt(created_time) - if created_time and created_time.strip() - else None, updated_time=parse_dt(updated_time) if updated_time and updated_time.strip() else None, @@ -648,35 +643,104 @@ def from_api( @attr.s(auto_attribs=True, slots=True, frozen=True) -class NftCollectionStats: - coin: Coin +class NftCollectionIntervalStats: volume: Decimal - market_cap: Decimal - floor_price: Decimal + volume_diff: Decimal + volume_percent_change: Decimal + sales_count: int + sales_diff: int average_price: Decimal + + @classmethod + def from_api( + cls, + *, + volume: str, + volume_diff: str, + volume_percent_change: str, + sales_count: str, + sales_diff: str, + average_price: str, + ) -> 'NftCollectionIntervalStats': + return cls( + volume=Decimal(volume), + volume_diff=Decimal(volume_diff), + volume_percent_change=Decimal(volume_percent_change), + sales_count=int(sales_count) if sales_count else 0, + sales_diff=int(Decimal(sales_diff)) if sales_diff else 0, + average_price=Decimal(average_price), + ) + + +@attr.s(auto_attribs=True, slots=True, frozen=True) +class NftCollectionTotalStats: + volume: Decimal sales_count: int owners_count: int + market_cap: Decimal + floor_price: Decimal + coin: Coin + average_price: Decimal @classmethod def from_api( cls, *, - coin: Coin, volume: str, + sales_count: str, + owners_count: str, market_cap: str, floor_price: str, average_price: str, - sales_count: str, - owners_count: str, - ): - return NftCollectionStats( - coin=coin, + coin: Coin, + ) -> 'NftCollectionTotalStats': + return cls( volume=Decimal(volume), + sales_count=int(sales_count) if sales_count else 0, + owners_count=int(owners_count) if owners_count else 0, market_cap=Decimal(market_cap), floor_price=Decimal(floor_price), average_price=Decimal(average_price), - sales_count=int(sales_count) if sales_count else 0, - owners_count=int(owners_count) if owners_count else 0, + coin=coin, + ) + + +@attr.s(auto_attribs=True, slots=True, frozen=True) +class NftCollection: + ident: str + name: str + image: Optional[str] + is_disabled: bool + is_nsfw: bool + total_stats: NftCollectionTotalStats + day_stats: NftCollectionIntervalStats + week_stats: NftCollectionIntervalStats + month_stats: NftCollectionIntervalStats + + @classmethod + def from_api( + cls, + *, + ident: str, + name: str, + image: Optional[str], + is_disabled: bool, + is_nsfw: bool, + total_stats: NftCollectionTotalStats, + day_stats: Optional[NftCollectionIntervalStats], + week_stats: Optional[NftCollectionIntervalStats], + month_stats: Optional[NftCollectionIntervalStats], + ) -> 'NftCollection': + return cls( + ident=ident, + name=name, + image=image, + is_disabled=is_disabled, + is_nsfw=is_nsfw, + total_stats=total_stats, + day_stats=day_stats, + week_stats=week_stats, + month_stats=month_stats, ) @@ -803,7 +867,7 @@ def json(self): @attr.s(auto_attribs=True, slots=True, frozen=True) class ParseResult: data: Optional[ - list[Union[BalanceItem, Pool, NftToken, NftCollectionStats, NftOffer]] + list[Union[BalanceItem, Pool, NftToken, NftCollection, NftOffer]] ] = None warnings: Optional[list[Union[str, dict]]] = None errors: Optional[list[Union[str, dict]]] = None From 07be5eb93dbe997fd0124bba76a98fd18d9c2e79 Mon Sep 17 00:00:00 2001 From: hanjano Date: Wed, 18 Oct 2023 09:45:09 +0200 Subject: [PATCH 3/3] fix: Cleanup --- blockapi/test/v2/api/nft/test_opensea.py | 1 - 1 file changed, 1 deletion(-) diff --git a/blockapi/test/v2/api/nft/test_opensea.py b/blockapi/test/v2/api/nft/test_opensea.py index fb883d36..7b673f7e 100644 --- a/blockapi/test/v2/api/nft/test_opensea.py +++ b/blockapi/test/v2/api/nft/test_opensea.py @@ -158,7 +158,6 @@ def test_parse_listings(requests_mock, api, listings_response): assert data.direction == NftOfferDirection.LISTING assert data.collection == 'ever-fragments-of-civitas' - # assert data.contract == '0x8acb0bc7f6c77e4e2aef83ea928d5a6c2a0b7fcd' assert data.start_time == datetime.datetime( 2023, 5, 22, 14, 44, 17, tzinfo=datetime.timezone.utc )