DEV Community

Devil Scrapes
Devil Scrapes

Posted on

Fiverr Gig Scraper: One Listing, Two Rows, Only One Says Sponsored

Quick answer

Fiverr's own gig-search page duplicates listings inside a single result page — and hides the duplication behind its own sponsorship flag. We pulled a live "logo design" search, page 1, through Apify's residential proxy on 2026-09-12: of the 48 gigs returned, 9 shared a gigId with another gig on the same page — only 39 were actually unique. Every one of those 9 pairs showed the identical listing twice: once tagged "type": "promoted_gigs" (a paid placement) and once with no type at all, indistinguishable from an untouched organic result. Re-running the same query on a fresh proxy session reproduced 9 duplicates again; page 2 of the same query had 3; a different keyword ("wordpress developer") had 3 more. If your pipeline filters isSponsored == false expecting "just the organic gigs," the sponsored seller's exact same data walks right back in through the unflagged copy.

Full field reference, input options, and pricing live on the Fiverr Gig Listings Scraper Store listing.

Watching the duplicate hide in real search data 🕵️

Here's the Actor's own parsing code — extract_perseus_props + extract_gigs, no HTML markup parsing, straight JSON extraction from the embedded perseus-initial-props script tag — run against the real response body we captured:

from src.parser import extract_perseus_props, extract_gigs

html = ...  # body of GET https://www.fiverr.com/search/gigs?query=logo+design&page=1
props = extract_perseus_props(html)
gigs = extract_gigs(props)

seen: dict[int, list[tuple[int, str | None]]] = {}
for i, gig in enumerate(gigs):
    gid = gig.get("gigId") or gig.get("gig_id")
    seen.setdefault(gid, []).append((i, gig.get("type")))

for gig_id, hits in seen.items():
    if len(hits) > 1:
        print(gig_id, hits)
Enter fullscreen mode Exit fullscreen mode

Real output, captured live 2026-09-12 ((page index, type) pairs):

3477079   [(0, 'promoted_gigs'), (19, None)]
156202007 [(1, 'promoted_gigs'), (7, None)]
441973523 [(2, 'promoted_gigs'), (18, None)]
146927460 [(6, None), (11, 'promoted_gigs')]
466786112 [(8, 'promoted_gigs'), (43, None)]
238180463 [(9, 'promoted_gigs'), (37, None)]
286340835 [(10, 'promoted_gigs'), (13, None)]
500529769 [(12, None), (34, 'promoted_gigs')]
457083769 [(22, 'promoted_gigs'), (40, None)]
Enter fullscreen mode Exit fullscreen mode

Nine gigs, nine pairs, and in every single case one copy carries the sponsorship marker and the other doesn't. This isn't a fluke of one page — 21 of the 48 gigs on this page carried a non-null type at all (43.8%), so nearly half of "page 1" is paid placement to begin with, and a chunk of those sellers bought a second, unflagged appearance on the same page for free.

Why "isSponsored == false" doesn't mean "organic" here

This Actor's isSponsored field does exactly what it says per row — bool(gig.get("type")), computed independently for each entry Fiverr returns. That part is honest. The trap sits one level up: because Fiverr itself repeats the gig, a naive df[df.isSponsored == False] filter, a "how many unique sellers rank for logo design" count, or a price-benchmark average will still include the paid listing's numbers, just laundered through its unflagged twin. On this one page, that's 9 of 39 real gigs — close to a quarter of the actual content — quietly appearing as if it ranked organically when it also bought a slot.

Practical fix: dedupe by gigId first, and if a gigId shows isSponsored: true on any of its rows, treat it as sponsored everywhere, even where a given copy shows false. (One more small honesty check while we were in there: gig 3477079's gigUrl slug — do-lovely-ebook-or-kindle-cover-for-you — doesn't match its "boho logo design" title at all. Fiverr's own internal field name for that value is literally cached_slug, and this is exactly the kind of case that name is warning you about.)

The hourly_rate field is quoted in cents — confirmed against live gigs, not docs 🧮

Fiverr's gig-search response has no public schema, so field units are whatever we can verify, not whatever's documented. hourly_rate is one worth checking: it sits next to packages.recommended.price and price_i, which are already whole-currency-unit dollars, but hourly_rate isn't. From the same live page, 21 gigs carried a non-null hourly_rate. Every single value divided cleanly by 100 with no remainder:

hourly_rate_raw = [2500, 2000, 2500, 1200, 1500, 2500, 1500, 3500, 1000, 5000]  # gig["hourly_rate"], live 2026-09-12
[h / 100 for h in hourly_rate_raw]
# [25.0, 20.0, 25.0, 12.0, 15.0, 25.0, 15.0, 35.0, 10.0, 50.0]
Enter fullscreen mode Exit fullscreen mode

$10–$50/hr is the plausible band for a $5–$45 fixed-price logo gig. Left un-divided, the same field claims $1,000–$5,000/hr, which nobody is charging for a starter logo package. This Actor's hourlyRate output field ships already divided — you never see the raw cents value or have to guess.

What we handle for you 🛡️

We checked, today, exactly what happens when Fiverr doesn't recognize the request. A bare curl and a curl-cffi request impersonating Chrome 131 — no proxy — both got an instant 403, served by Cloudflare, carrying a PerimeterX challenge page titled "It needs a human touch" and an fvrr-bl-route-id: px header. That's a real bot gateway sitting in front of Fiverr's own search page, not a soft rate limit. The identical request, routed through Apify's residential proxy pinned to country-US, came back clean: 200, a real ~2.4 MB page, the full perseus-initial-props payload intact.

Closing that gap, every run, is the actual job. We rotate through Chrome / Firefox / Safari TLS fingerprints via curl-cffi impersonation, route every request through a rotating residential exit IP pinned to your chosen country, retry with exponential backoff on 408 / 429 / 503 / 504, and roll to a fresh proxy session rather than hammering an exit that already drew a 403. A page that genuinely has no matches (gigs: [], pagination.total: 0) finishes the run successfully with zero rows and a clear status message — we don't fail your whole run over a narrow keyword, and we don't fabricate rows to make a blocked page look like data either.

Output

Real ResultRow, produced by this Actor's own gig_to_row() against the exact gig 3477079 from above — this is its unsponsored copy (index 19, type: None):

{
  "gigId": 3477079,
  "title": "design a unique chic boho hand drawn luxurious logo for your brand or business",
  "sellerName": "design_desk",
  "sellerId": 2956287,
  "sellerCountry": "IN",
  "sellerOnline": false,
  "isPro": false,
  "isSponsored": false,
  "sellerRatingScore": 4.922002,
  "sellerRatingCount": 42528,
  "priceFrom": 30.0,
  "packageType": "cheapest",
  "deliveryDays": 3,
  "hourlyRate": 25.0,
  "categoryId": 3,
  "subCategoryId": 49,
  "thumbnailUrl": "https://fiverr-res.cloudinary.com/t_main1,q_auto,f_auto/gigs/3477079/original/2678d121e1583d1bd3ea0b169b68b7812febaaf2.jpg",
  "gigUrl": "https://www.fiverr.com/design_desk/do-lovely-ebook-or-kindle-cover-for-you",
  "sourceKeyword": "logo design",
  "sourcePage": 1,
  "scrapedAt": "2026-09-12T00:00:00Z"
}
Enter fullscreen mode Exit fullscreen mode
from apify_client import ApifyClient

client = ApifyClient("<YOUR_API_TOKEN>")
run = client.actor("DevilScrapes/fiverr-gig-listings-scraper").call(
    run_input={"keywords": ["logo design", "wordpress developer"], "maxPagesPerKeyword": 2}
)
for item in client.dataset(run["defaultDatasetId"]).iterate_items():
    print(item["gigId"], item["sellerName"], "sponsored=", item["isSponsored"])
Enter fullscreen mode Exit fullscreen mode

Pricing: $0.20 flat per run — no per-result charge. A run that pulls back 1,000 gigs costs the same $0.20 as a run that pulls back 10. A search that hits maxRuntimeSecs or finds nothing for a narrow keyword still succeeds; you're never billed for a run the target blocked outright.

Fiverr Gig Listings Scraper on Apify


Built by Devil Scrapes. We rotate real browser TLS fingerprints and residential exit IPs on every fetch, and we go looking for the gig that shows up twice on the same page before your dedup logic finds it the hard way.

Top comments (0)