Your catalogue still renders, but imageUrls.smallS3 appears in one pull and disappears in another. Before you publish a delivery-path comparison, there's a boundary inside the collector to account for.
The Actor checks up to 100 premium photos for an S3 copy per detail run.
That makes an absent S3 URL ambiguous: the copy might not have responded, or the photo might not have received a check. The fallback uses the always-present plus.unsplash.com variants, so a working preview can conceal a change in probe coverage.
Keep the input beside the pull
A host comparison needs the collection settings that produced its rows. Without them, you can't distinguish a changed delivery path from a changed population or an optional pass being disabled.
Use this Example search input to keep the request beside its returned records.
import os
from urllib.parse import urlsplit
import pandas as pd
from apify_client import ApifyClient
S3_CHECK_CAP = 100
run_input = {
"mode": "search",
"searchQueries": ["nature"],
"orientation": "any",
"freeOnly": False,
"includePhotoDetails": True,
"maxItems": 1000,
}
client = ApifyClient(os.environ["APIFY_TOKEN"])
run = client.actor(
"dataspryva/unsplash-photo-search-by-topic-collection"
).call(run_input=run_input)
if run["status"] != "SUCCEEDED":
raise RuntimeError(f"Collection ended with {run['status']}")
items = client.dataset(run["defaultDatasetId"]).list_items().items
pulls = [{"pull": "catalogue-a", "input": run_input.copy(), "items": items}]
For comparisons, retain each collection in pulls with a distinct label, its actual input and its returned items. A label identifies a pull; it doesn't establish that its settings match another pull.
Keep this distinction in view:
premium photo
|
+-- checked, copy responds ------------------> smallS3 included
|
+-- no responding copy returned ------------> plus variants
returned URL ------------------------------------> metadata evidence
successful image transfer -----------------------> separate measurement
A cap tells you a maximum, not a sampling design. It gives you no basis for treating the checked photos as a random sample or inferring check order from exported row order.
That matters if you were about to turn the share of records containing smallS3 into an estimate of storage availability. The denominator includes photos whose probe opportunity you can't establish.
What population can reach the catalogue?
This is public website photo data. Keyword searches, curated topics, collections, portfolios, specific photos and related-photo feeds define different populations. Illustrations are excluded.
These boundaries matter before any host is counted:
| Coverage dimension | Boundary | Consequence for the audit |
|---|---|---|
| Retrieval depth |
maxItems limits emitted photos across all supplied sources; keyword searches also have a 4,000-row window |
A returned catalogue isn't a census of matching photography |
| Filters and ordering | Orientation applies in every mode; dominant colour filters keyword searches; freeOnly excludes premium photos |
Changing an input can change the population behind the host shares |
| Geography | Detail location fields depend on photographer-supplied information | Missing coordinates don't establish where a photo was taken |
| Time | Each run reads live data, without caching between runs | Repeated discovery pulls aren't guaranteed to return a fixed population |
| Optional fields | Details add tags, EXIF, location and free-photo counts when available; empty fields are omitted | An absent field needs a different interpretation from a measured zero |
There isn't a publication-date range input. Newest-first ordering is an ordering choice, not a bounded time window.
Premium per-photo views and downloads are suppressed. Photographer lifetime totals don't fill that gap: they describe a photographer, not the individual asset you're comparing.
For this audit, geography and category are population descriptors rather than explanations of delivery behaviour. A host difference between topics could reflect different assets, different premium composition or different probe opportunity. The category label alone doesn't distinguish those explanations.
Count identifiable premium photos first
A missing premium marker shouldn't quietly become “free”. Nor should a non-empty plus value become a Boolean merely because Python considers it truthy.
The following cell accepts explicit Boolean markers and recognised licence values, leaves disagreements unresolved, and preserves the original flags for inspection.
def premium_class(item):
signals = [
item.get(key)
for key in ("isPremium", "premium", "plus")
if type(item.get(key)) is bool
]
licence_signal = {
"Unsplash License": False,
"Unsplash+ License": True,
}.get(item.get("license"))
if licence_signal is not None:
signals.append(licence_signal)
if not signals or len(set(signals)) != 1:
return "unresolved"
return "premium" if signals[0] else "free"
photo_columns = [
"pull", "record", "id", "premium_class", "license",
"isPremium", "premium", "plus", "imageUrls",
]
photos = pd.DataFrame([
{
"pull": pull["pull"],
"record": position,
"id": (
item["id"].strip()
if isinstance(item.get("id"), str) and item["id"].strip()
else None
),
"premium_class": premium_class(item),
"license": item.get("license"),
"isPremium": item.get("isPremium"),
"premium": item.get("premium"),
"plus": item.get("plus"),
"imageUrls": item.get("imageUrls"),
}
for pull in pulls
for position, item in enumerate(pull["items"])
], columns=photo_columns)
Count distinct identifiable premium photos separately from emitted records. Duplicate rows shouldn't manufacture evidence that the probe ceiling was crossed.
This cell flags detail-enabled pulls whose returned premium population alone exceeds that ceiling.
photo_counts = (
photos.assign(
premium_record=photos["premium_class"].eq("premium"),
premium_id=photos["id"].where(
photos["premium_class"].eq("premium")
),
unresolved_record=photos["premium_class"].eq("unresolved"),
unidentified_record=photos["id"].isna(),
)
.groupby("pull")
.agg(
emitted_records=("record", "size"),
premium_records=("premium_record", "sum"),
identifiable_premium_photos=("premium_id", "nunique"),
unresolved_records=("unresolved_record", "sum"),
unidentified_records=("unidentified_record", "sum"),
)
.reset_index()
)
manifests = pd.DataFrame([
{
"pull": pull["pull"],
"mode": pull["input"]["mode"],
"details_enabled": pull["input"].get("includePhotoDetails", False),
"requested_max": pull["input"].get("maxItems", 20),
}
for pull in pulls
])
coverage = manifests.merge(photo_counts, on="pull", how="left")
count_columns = list(photo_counts.columns.drop("pull"))
coverage[count_columns] = coverage[count_columns].fillna(0).astype(int)
coverage["probe_constraint"] = (
coverage["details_enabled"]
& coverage["identifiable_premium_photos"].gt(S3_CHECK_CAP)
)
coverage is a boundary check, not a probe log. An unflagged pull doesn't prove every premium photo was checked, and unresolved identities can hide part of the population.
Don't interpret the flag as a completion percentage. Dividing the allowance by the returned premium count wouldn't identify which photos were checked or how those checks ended.
The collector doesn't expose a per-photo check outcome here. Missingness can't supply one by sheer determination.
Preserve missing renditions in the denominator
Counting only URLs that exist makes delivery coverage look unusually tidy. Every missing rendition disappears before the chart gets a chance to notice it.
Instead, create a slot for each named rendition on every emitted record. Preserve the photo ID and record position so variants don't become separate photos.
This cell also keeps an unexpected imageUrls shape distinct from an omitted object.
renditions = ("raw", "full", "regular", "small", "thumb", "smallS3")
def url_host(value):
if not isinstance(value, str) or not value.strip():
return None
try:
parsed = urlsplit(value.strip())
return parsed.hostname if parsed.scheme in ("http", "https") else None
except ValueError:
return None
slots = []
for photo in photos.itertuples(index=False):
variants = photo.imageUrls if isinstance(photo.imageUrls, dict) else {}
map_state = (
"object" if isinstance(photo.imageUrls, dict)
else "omitted" if photo.imageUrls is None
else "unexpected"
)
for rendition in renditions:
value = variants.get(rendition)
host = url_host(value)
slots.append({
"pull": photo.pull,
"record": photo.record,
"id": photo.id,
"premium_class": photo.premium_class,
"license": photo.license,
"rendition": rendition,
"map_state": map_state,
"url_state": (
"present" if host
else "missing" if value is None or value == ""
else "unparsed"
),
"host": host,
})
url_slots = pd.DataFrame(slots, columns=[
"pull", "record", "id", "premium_class", "license",
"rendition", "map_state", "url_state", "host",
])
A host identifies the hostname written in a URL. It doesn't establish the physical serving infrastructure or whether a request would succeed.
The separate shape and URL states also prevent a parsing problem from masquerading as a delivery change. If the export contains something other than the expected object, that needs investigation before it becomes a missing-image statistic.
For premium photos, an absent smallS3 slot describes missing optional URL metadata. It doesn't mean the application has no preview to display.
Compare like renditions
A thumbnail URL and a full-size URL aren't interchangeable observations. Combining their hosts gives you a mixture of requested renditions rather than a useful delivery comparison.
This cell computes host shares within each pull, premium classification, licence and rendition, retaining missing and unparsed slots.
coverage_dimensions = ["pull", "premium_class", "license", "rendition"]
host_coverage = (
url_slots.assign(host=url_slots["host"].fillna("<no parsed host>"))
.groupby(
coverage_dimensions + ["map_state", "url_state", "host"],
as_index=False,
dropna=False,
)
.agg(emitted_records=("record", "size"))
)
host_coverage["record_share"] = (
host_coverage["emitted_records"]
/ host_coverage.groupby(
coverage_dimensions, dropna=False
)["emitted_records"].transform("sum")
)
host_coverage counts emitted record slots. Repeated photo IDs remain repeated records; the distinct-photo count in coverage answers a different question.
That choice depends on what you're investigating. Record weighting describes the metadata your ingestion received; weighting each photo equally describes the asset population. Silently deduplicating before deciding which question matters can hide repeated ingestion just as easily as it can remove duplication.
Keep the unresolved classification visible during comparisons. Dropping it changes the population to photos you could classify, which may differ between pulls.
Give follow-up pulls the same targets
Discovery pulls are useful for finding assets. They're awkward controls for investigating whether the same assets receive different URL metadata.
The worked source is Unsplash Photo Search by Topic, Collection & Creator. Its smallS3 field is conditional, while imageUrls.full is a rendition name. Neither field is a probe-status flag.
Generate a fixed target list from identifiable premium photos, then reuse each batch unchanged across follow-up pulls. Keeping the entire batch within the probe allowance removes population size as a reason for exceeding that allowance. It doesn't guarantee a responding S3 copy.
This cell prepares inputs without launching additional runs.
premium_targets = sorted(
photos.loc[
photos["premium_class"].eq("premium") & photos["id"].notna(),
"id",
].unique()
)
follow_up_inputs = [
{
"mode": "byPhoto",
"photoIds": premium_targets[start:start + S3_CHECK_CAP],
"includePhotoDetails": True,
"orientation": "any",
"freeOnly": False,
"maxItems": len(premium_targets[start:start + S3_CHECK_CAP]),
}
for start in range(0, len(premium_targets), S3_CHECK_CAP)
]
Before comparing those follow-ups, join returned records by id and account for requested targets that didn't return. Matching inputs aren't evidence of matching outputs.
A comparison restricted to IDs returned in every pull answers whether URL metadata changed among those surviving targets. It doesn't answer whether target retrieval changed, so keep missing targets beside that comparison rather than losing them in an inner join.
This follow-up population also excludes unresolved premium classifications and missing IDs. Keep that exclusion visible rather than quietly promoting it into catalogue-wide coverage.
Put the boundary on the chart
A defensible chart label is “Returned URL hosts by rendition”, with the population described as emitted photo records under the saved inputs.
Keep the premium classification and missing-rendition share visible. Place the detail-pass setting and probe_constraint beside any comparison involving S3 metadata.
If you publish matched follow-ups, name that narrower population in the caption. “Identifiable premium targets returned in every compared pull” tells the reader what survived the join without pretending it represents all premium photography.
Avoid “S3 availability” unless you've check outcomes. Avoid “download success” unless you measured transfers. A denominator deserves a name that survives a sceptical code review.
A working preview doesn't establish full-size access
Premium full-size files require a separate Unsplash+ plan, and premium CDN images are watermarked. A reachable downloadUrl doesn't erase that distinction.
The Actor returns metadata and URLs, not image binaries. This audit therefore establishes neither transfer latency nor binary integrity, successful decoding or access to an original asset.
A preview can work perfectly while your explanation of why it worked is wrong.
Top comments (0)