feat: Add gift and number search functionality to FragmentClient

- Implemented `search_gifts` method for searching gifts in the Fragment marketplace.
- Implemented `search_numbers` method for searching anonymous Telegram numbers.
- Added new example scripts for searching gifts, numbers, and usernames.
- Updated HTML parsing logic to handle gift items and number listings.
- Added unit tests for the new search methods and updated existing tests for usernames and numbers.
- Removed obsolete test file for auction searches.
This commit is contained in:
bohd4nx
2026-03-25 18:01:22 +02:00
parent f37a4ab985
commit 8163ed1a9d
20 changed files with 877 additions and 179 deletions
+111
View File
@@ -0,0 +1,111 @@
"""Unit tests for search_usernames — Fragment marketplace username search."""
from unittest.mock import AsyncMock, patch
import pytest
from pyfragment import FragmentClient
from pyfragment.types import UsernamesResult
from tests.shared import FAKE_HASH
FAKE_HTML = """
<tr class="tm-row-selectable">
<td><a href="/username/coolname" class="table-cell">
<div class="table-cell-value tm-value">@coolname</div>
<div class="table-cell-status-thin thin-only tm-status-avail">On auction</div>
</a></td>
<td><div class="table-cell-value tm-value icon-before icon-ton">5</div>
<time datetime="2026-06-01T12:00:00+00:00" data-relative="text">2 days</time>
</td>
<td><div class="table-cell-value tm-value tm-status-avail">On auction</div></td>
</tr>
"""
@pytest.mark.asyncio
async def test_search_usernames_basic(client: FragmentClient) -> None:
with (
patch("pyfragment.methods.search_usernames.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
patch(
"pyfragment.methods.search_usernames.fragment_request",
AsyncMock(return_value={"ok": True, "html": FAKE_HTML}),
),
):
result = await client.search_usernames("coolname")
assert isinstance(result, UsernamesResult)
assert len(result.items) == 1
assert result.items[0]["slug"] == "username/coolname"
assert result.items[0]["name"] == "@coolname"
assert result.items[0]["date"] == "2026-06-01T12:00:00+00:00"
assert result.next_offset_id is None
@pytest.mark.asyncio
async def test_search_usernames_empty_html(client: FragmentClient) -> None:
with (
patch("pyfragment.methods.search_usernames.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
patch(
"pyfragment.methods.search_usernames.fragment_request",
AsyncMock(return_value={"ok": True}),
),
):
result = await client.search_usernames("zzz_no_results")
assert isinstance(result, UsernamesResult)
assert result.items == []
assert result.next_offset_id is None
@pytest.mark.asyncio
async def test_search_usernames_with_sort_and_filter(client: FragmentClient) -> None:
with (
patch("pyfragment.methods.search_usernames.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
patch(
"pyfragment.methods.search_usernames.fragment_request",
AsyncMock(return_value={"ok": True, "html": FAKE_HTML}),
) as mock_request,
):
result = await client.search_usernames("durov", sort="price_desc", filter="auction")
assert isinstance(result, UsernamesResult)
call_data = mock_request.call_args[0][3]
assert call_data["type"] == "usernames"
assert call_data["method"] == "searchAuctions"
assert call_data["sort"] == "price_desc"
assert call_data["filter"] == "auction"
assert call_data["query"] == "durov"
@pytest.mark.asyncio
async def test_search_usernames_with_offset_id(client: FragmentClient) -> None:
with (
patch("pyfragment.methods.search_usernames.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
patch(
"pyfragment.methods.search_usernames.fragment_request",
AsyncMock(return_value={"ok": True, "html": FAKE_HTML, "next_offset_id": "offset_99"}),
) as mock_request,
):
result = await client.search_usernames("durov", offset_id="offset_10")
assert isinstance(result, UsernamesResult)
assert result.next_offset_id == "offset_99"
call_data = mock_request.call_args[0][3]
assert call_data["offset_id"] == "offset_10"
@pytest.mark.asyncio
async def test_search_usernames_default_query(client: FragmentClient) -> None:
with (
patch("pyfragment.methods.search_usernames.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
patch(
"pyfragment.methods.search_usernames.fragment_request",
AsyncMock(return_value={"ok": True, "html": FAKE_HTML}),
) as mock_request,
):
result = await client.search_usernames()
assert isinstance(result, UsernamesResult)
call_data = mock_request.call_args[0][3]
assert call_data["query"] == ""
assert call_data["type"] == "usernames"
+111
View File
@@ -0,0 +1,111 @@
"""Unit tests for search_numbers — Fragment marketplace number search."""
from unittest.mock import AsyncMock, patch
import pytest
from pyfragment import FragmentClient
from pyfragment.types import NumbersResult
from tests.shared import FAKE_HASH
FAKE_HTML = """
<tr class="tm-row-selectable">
<td><a href="/number/8880000888" class="table-cell">
<div class="table-cell-value tm-value">+888 0000 888</div>
<div class="table-cell-status-thin thin-only tm-status-avail">For sale</div>
</a></td>
<td><div class="table-cell-value tm-value icon-before icon-ton">150</div>
<time datetime="2026-05-15T10:00:00+00:00" data-relative="short-text">May 15</time>
</td>
<td><div class="table-cell-value tm-value tm-status-avail">For sale</div></td>
</tr>
"""
@pytest.mark.asyncio
async def test_search_numbers_basic(client: FragmentClient) -> None:
with (
patch("pyfragment.methods.search_numbers.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
patch(
"pyfragment.methods.search_numbers.fragment_request",
AsyncMock(return_value={"ok": True, "html": FAKE_HTML}),
),
):
result = await client.search_numbers("888")
assert isinstance(result, NumbersResult)
assert len(result.items) == 1
assert result.items[0]["slug"] == "number/8880000888"
assert result.items[0]["name"] == "+888 0000 888"
assert result.items[0]["date"] == "2026-05-15T10:00:00+00:00"
assert result.next_offset_id is None
@pytest.mark.asyncio
async def test_search_numbers_empty_html(client: FragmentClient) -> None:
with (
patch("pyfragment.methods.search_numbers.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
patch(
"pyfragment.methods.search_numbers.fragment_request",
AsyncMock(return_value={"ok": True}),
),
):
result = await client.search_numbers("zzz_no_results")
assert isinstance(result, NumbersResult)
assert result.items == []
assert result.next_offset_id is None
@pytest.mark.asyncio
async def test_search_numbers_with_sort_and_filter(client: FragmentClient) -> None:
with (
patch("pyfragment.methods.search_numbers.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
patch(
"pyfragment.methods.search_numbers.fragment_request",
AsyncMock(return_value={"ok": True, "html": FAKE_HTML}),
) as mock_request,
):
result = await client.search_numbers("888", sort="price_asc", filter="sale")
assert isinstance(result, NumbersResult)
call_data = mock_request.call_args[0][3]
assert call_data["type"] == "numbers"
assert call_data["method"] == "searchAuctions"
assert call_data["sort"] == "price_asc"
assert call_data["filter"] == "sale"
assert call_data["query"] == "888"
@pytest.mark.asyncio
async def test_search_numbers_with_offset_id(client: FragmentClient) -> None:
with (
patch("pyfragment.methods.search_numbers.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
patch(
"pyfragment.methods.search_numbers.fragment_request",
AsyncMock(return_value={"ok": True, "html": FAKE_HTML, "next_offset_id": "offset_50"}),
) as mock_request,
):
result = await client.search_numbers("888", offset_id="offset_50")
assert isinstance(result, NumbersResult)
assert result.next_offset_id == "offset_50"
call_data = mock_request.call_args[0][3]
assert call_data["offset_id"] == "offset_50"
@pytest.mark.asyncio
async def test_search_numbers_default_query(client: FragmentClient) -> None:
with (
patch("pyfragment.methods.search_numbers.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
patch(
"pyfragment.methods.search_numbers.fragment_request",
AsyncMock(return_value={"ok": True, "html": FAKE_HTML}),
) as mock_request,
):
result = await client.search_numbers()
assert isinstance(result, NumbersResult)
call_data = mock_request.call_args[0][3]
assert call_data["query"] == ""
assert call_data["type"] == "numbers"
-124
View File
@@ -1,124 +0,0 @@
"""Unit tests for search_auctions — Fragment marketplace search."""
from unittest.mock import AsyncMock, patch
import pytest
from pyfragment import FragmentClient
from pyfragment.types import AuctionsResult
from tests.shared import FAKE_HASH
FAKE_HTML = """
<tr class="tm-row-selectable">
<td><a href="/username/coolname" class="table-cell">
<div class="table-cell-value tm-value">@coolname</div>
<div class="table-cell-status-thin thin-only tm-status-avail">On auction</div>
</a></td>
<td><div class="table-cell-value tm-value icon-before icon-ton">5</div>
<time datetime="2026-06-01T12:00:00+00:00" data-relative="text">2 days</time>
</td>
<td><div class="table-cell-value tm-value tm-status-avail">On auction</div></td>
</tr>
"""
@pytest.mark.asyncio
async def test_search_auctions_basic(client: FragmentClient) -> None:
with (
patch("pyfragment.methods.search_auctions.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
patch(
"pyfragment.methods.search_auctions.fragment_request",
AsyncMock(return_value={"ok": True, "html": FAKE_HTML}),
),
):
result = await client.search_auctions("coolname")
assert isinstance(result, AuctionsResult)
assert len(result.items) == 1
assert result.items[0]["slug"] == "username/coolname"
assert result.items[0]["name"] == "@coolname"
assert result.next_offset_id is None
@pytest.mark.asyncio
async def test_search_auctions_with_type(client: FragmentClient) -> None:
with (
patch("pyfragment.methods.search_auctions.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
patch(
"pyfragment.methods.search_auctions.fragment_request",
AsyncMock(return_value={"ok": True, "html": FAKE_HTML, "next_offset_id": "offset_99"}),
),
):
result = await client.search_auctions("coolname", type="usernames")
assert isinstance(result, AuctionsResult)
assert len(result.items) == 1
assert result.next_offset_id == "offset_99"
@pytest.mark.asyncio
async def test_search_auctions_numbers(client: FragmentClient) -> None:
with (
patch("pyfragment.methods.search_auctions.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
patch(
"pyfragment.methods.search_auctions.fragment_request",
AsyncMock(return_value={"ok": True, "html": FAKE_HTML}),
),
):
result = await client.search_auctions("888", type="numbers")
assert isinstance(result, AuctionsResult)
assert isinstance(result.items, list)
@pytest.mark.asyncio
async def test_search_auctions_empty_html(client: FragmentClient) -> None:
with (
patch("pyfragment.methods.search_auctions.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
patch(
"pyfragment.methods.search_auctions.fragment_request",
AsyncMock(return_value={"ok": True}),
),
):
result = await client.search_auctions("zzz_no_results")
assert isinstance(result, AuctionsResult)
assert result.items == []
assert result.next_offset_id is None
@pytest.mark.asyncio
async def test_search_auctions_with_sort_and_filter(client: FragmentClient) -> None:
with (
patch("pyfragment.methods.search_auctions.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
patch(
"pyfragment.methods.search_auctions.fragment_request",
AsyncMock(return_value={"ok": True, "html": FAKE_HTML}),
) as mock_request,
):
result = await client.search_auctions("durov", type="usernames", sort="price_desc", filter="auction")
assert isinstance(result, AuctionsResult)
call_data = mock_request.call_args[0][3]
assert call_data["sort"] == "price_desc"
assert call_data["filter"] == "auction"
assert call_data["type"] == "usernames"
assert call_data["query"] == "durov"
@pytest.mark.asyncio
async def test_search_auctions_with_offset_id(client: FragmentClient) -> None:
with (
patch("pyfragment.methods.search_auctions.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
patch(
"pyfragment.methods.search_auctions.fragment_request",
AsyncMock(return_value={"ok": True, "html": FAKE_HTML}),
) as mock_request,
):
result = await client.search_auctions("durov", type="usernames", offset_id="offset_10")
assert isinstance(result, AuctionsResult)
call_data = mock_request.call_args[0][3]
assert call_data["offset_id"] == "offset_10"
assert call_data["type"] == "usernames"
assert call_data["query"] == "durov"
+188
View File
@@ -0,0 +1,188 @@
"""Unit tests for search_gifts — Fragment gifts marketplace search."""
from unittest.mock import AsyncMock, patch
import pytest
from pyfragment import FragmentClient
from pyfragment.types import GiftsResult
from tests.shared import FAKE_HASH
FAKE_GIFTS_HTML = """
<div class="tm-catalog-grid">
<a href="/gift/plushpepe-1821?collection=all" class="tm-grid-item">
<div class="tm-grid-item-thumb">
<img src="https://nft.fragment.com/gift/plushpepe-1821.medium.jpg" class="tm-grid-thumb"/>
</div>
<div class="tm-grid-item-content">
<div class="tm-grid-item-name wide-only">
<span class="item-name">Plush Pepe</span>
<span class="item-num">&nbsp;#1821</span>
</div>
<div class="tm-grid-item-desc wide-only">
<time datetime="2026-02-05T14:41:27+00:00" class="short">Feb 5 at 16:41</time>
</div>
<div class="tm-grid-item-values">
<div class="tm-grid-item-value tm-value icon-before icon-ton">88,888</div>
<div class="tm-grid-item-status tm-status-unavail">Sold</div>
</div>
</div>
</a>
<a href="/gift/swisswatch-7799?collection=all" class="tm-grid-item">
<div class="tm-grid-item-content">
<div class="tm-grid-item-name wide-only">
<span class="item-name">Swiss Watch</span>
<span class="item-num">&nbsp;#7799</span>
</div>
<div class="tm-grid-item-desc wide-only">
<time datetime="2026-01-10T04:52:59+00:00" class="short">Jan 10 at 06:52</time>
</div>
<div class="tm-grid-item-values">
<div class="tm-grid-item-value tm-value icon-before icon-ton">13,588</div>
<div class="tm-grid-item-status tm-status-unavail">Sold</div>
</div>
</div>
</a>
<a class="tm-catalog-grid-more js-load-more" data-next-offset="60">Show more</a>
</div>
"""
@pytest.mark.asyncio
async def test_search_gifts_basic(client: FragmentClient) -> None:
with (
patch("pyfragment.methods.search_gifts.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
patch(
"pyfragment.methods.search_gifts.fragment_request",
AsyncMock(return_value={"ok": True, "html": FAKE_GIFTS_HTML}),
),
):
result = await client.search_gifts()
assert isinstance(result, GiftsResult)
assert len(result.items) == 2
assert result.items[0]["slug"] == "gift/plushpepe-1821"
assert result.items[0]["name"] == "Plush Pepe #1821"
assert result.items[0]["status"] == "Sold"
assert result.items[0]["price"] == "88888.00"
assert result.items[0]["date"] == "2026-02-05T14:41:27+00:00"
assert result.items[1]["slug"] == "gift/swisswatch-7799"
assert result.items[1]["price"] == "13588.00"
assert result.next_offset == 60
@pytest.mark.asyncio
async def test_search_gifts_empty(client: FragmentClient) -> None:
with (
patch("pyfragment.methods.search_gifts.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
patch(
"pyfragment.methods.search_gifts.fragment_request",
AsyncMock(return_value={"ok": True}),
),
):
result = await client.search_gifts(query="zzz_no_results")
assert isinstance(result, GiftsResult)
assert result.items == []
assert result.next_offset is None
@pytest.mark.asyncio
async def test_search_gifts_with_collection_and_sort(client: FragmentClient) -> None:
with (
patch("pyfragment.methods.search_gifts.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
patch(
"pyfragment.methods.search_gifts.fragment_request",
AsyncMock(return_value={"ok": True, "html": FAKE_GIFTS_HTML}),
) as mock_request,
):
result = await client.search_gifts(collection="plushpepe", sort="price_desc", filter="sold")
assert isinstance(result, GiftsResult)
call_data = mock_request.call_args[0][3]
assert call_data["collection"] == "plushpepe"
assert call_data["sort"] == "price_desc"
assert call_data["filter"] == "sold"
assert call_data["type"] == "gifts"
assert call_data["method"] == "searchAuctions"
@pytest.mark.asyncio
async def test_search_gifts_with_offset(client: FragmentClient) -> None:
with (
patch("pyfragment.methods.search_gifts.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
patch(
"pyfragment.methods.search_gifts.fragment_request",
AsyncMock(return_value={"ok": True, "html": FAKE_GIFTS_HTML}),
) as mock_request,
):
result = await client.search_gifts(offset=60)
assert isinstance(result, GiftsResult)
call_data = mock_request.call_args[0][3]
assert call_data["offset"] == 60
@pytest.mark.asyncio
async def test_search_gifts_with_view(client: FragmentClient) -> None:
with (
patch("pyfragment.methods.search_gifts.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
patch(
"pyfragment.methods.search_gifts.fragment_request",
AsyncMock(return_value={"ok": True, "html": FAKE_GIFTS_HTML}),
) as mock_request,
):
result = await client.search_gifts(collection="artisanbrick", view="Model")
assert isinstance(result, GiftsResult)
call_data = mock_request.call_args[0][3]
assert call_data["view"] == "Model"
assert call_data["collection"] == "artisanbrick"
@pytest.mark.asyncio
async def test_search_gifts_with_attr(client: FragmentClient) -> None:
with (
patch("pyfragment.methods.search_gifts.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
patch(
"pyfragment.methods.search_gifts.fragment_request",
AsyncMock(return_value={"ok": True, "html": FAKE_GIFTS_HTML}),
) as mock_request,
):
result = await client.search_gifts(
collection="artisanbrick",
sort="listed",
filter="auction",
attr={
"Model": ["Delicate Wash", "Foosball", "Chocolate"],
"Backdrop": ["Celtic Blue", "Carrot Juice", "Orange"],
"Symbol": ["Crystal Ball", "Tetsubin", "Acorn"],
},
)
assert isinstance(result, GiftsResult)
call_data = mock_request.call_args[0][3]
assert call_data["attr[Model]"] == ["Delicate Wash", "Foosball", "Chocolate"]
assert call_data["attr[Backdrop]"] == ["Celtic Blue", "Carrot Juice", "Orange"]
assert call_data["attr[Symbol]"] == ["Crystal Ball", "Tetsubin", "Acorn"]
assert call_data["collection"] == "artisanbrick"
assert call_data["sort"] == "listed"
assert call_data["filter"] == "auction"
assert call_data["type"] == "gifts"
@pytest.mark.asyncio
async def test_search_gifts_attr_not_in_data_when_none(client: FragmentClient) -> None:
with (
patch("pyfragment.methods.search_gifts.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
patch(
"pyfragment.methods.search_gifts.fragment_request",
AsyncMock(return_value={"ok": True, "html": FAKE_GIFTS_HTML}),
) as mock_request,
):
result = await client.search_gifts()
assert isinstance(result, GiftsResult)
call_data = mock_request.call_args[0][3]
assert "view" not in call_data
assert not any(k.startswith("attr[") for k in call_data)