Product-page JSON looks easy to flatten until the first item has several colors, sizes, and variant-specific prices. If product fields and SKU fields share one row, product facts repeat and variant facts become ambiguous.
This tutorial turns a saved TikTok Shop product response into two related datasets: one product row and zero or more SKU rows. The transformation keeps IDs as strings, preserves nulls, carries region and currency into the output, and serializes option pairs without discarding their meaning.
Start with the product hierarchy
The Scrapeless scraper.tiktok.shop.page actor accepts a known product_id and region. Its response can include product identity, name, seller, nested price, aggregate stock, rating, review count, sold count, images, options, SKUs, categories, and shipping data. The Shop Page actor reference provides the request and response surface.
Those fields belong at two levels:
- The product table describes the page-level offer: product ID, returned region, seller, name, aggregate price and stock, rating, reviews, and sold count.
- The SKU table describes a sellable option combination: SKU ID, option pairs, variant price, quantity, and in-stock state.
One product can own many SKU rows. A product without a returned SKU array still deserves a product row.
Prerequisites
The transformer uses Python 3 and its standard library. It reads product-response.json, a raw response previously saved from an authorized API request, and writes products.csv and skus.csv.
For live collection, send a JSON request to Scrapeless with this actor envelope:
{
"actor": "scraper.tiktok.shop.page",
"input": {
"product_id": "YOUR_PRODUCT_ID",
"region": "GB"
}
}
Keep the actual product ID as a string. The region is part of the observation, not temporary request metadata.
Define explicit schemas
CSV has no nested object type, so decide which fields each file owns before writing rows.
The product schema used here contains:
collected_atproduct_idregionnameseller_id- product-level currency, sale price, and original price
- aggregate available quantity and in-stock state
- rating, review count, and sold count
The SKU schema contains:
collected_atproduct_idregionsku_id- serialized option pairs
- SKU currency, sale price, and original price
- SKU available quantity and in-stock state
- image URL when present
Product ID links the two files. sku_id identifies a particular variant record within the product.
Normalize values without erasing uncertainty
The following helpers preserve numeric-looking IDs as strings and convert option objects into one readable value. The option separator is a presentation choice; the raw JSON remains the authoritative source.
import csv
import json
from datetime import datetime, timezone
def as_text(value):
return "" if value is None else str(value)
def option_label(options):
pairs = []
for option in options or []:
if isinstance(option, dict):
name = as_text(option.get("name"))
value = as_text(option.get("value"))
pairs.append(f"{name}={value}")
else:
pairs.append(as_text(option))
return " | ".join(pairs)
Do not apply or 0 to counts or quantities. A missing value and a definite zero have different meanings. The CSV writer will emit an empty cell for Python None, preserving that distinction for import rules that recognize blanks as null.
Build the product and SKU rows
Some responses use nested price and stock objects. Read them defensively and use the returned region rather than silently substituting the requested value in analysis.
def normalize_product(raw, collected_at):
product_id = as_text(raw.get("product_id"))
price = raw.get("price") or {}
stock = raw.get("stock") or {}
seller = raw.get("seller") or {}
product_row = {
"collected_at": collected_at,
"product_id": product_id,
"region": raw.get("region"),
"name": raw.get("name"),
"seller_id": as_text(
raw.get("seller_id")
or (seller.get("seller_id") if isinstance(seller, dict) else seller)
),
"currency": price.get("currency"),
"sale_price": price.get("sale_price"),
"original_price": price.get("original_price"),
"available_quantity": stock.get("available_quantity"),
"in_stock": stock.get("in_stock"),
"rating": raw.get("rating"),
"review_count": raw.get("review_count"),
"sold_count": raw.get("sold_count"),
}
sku_rows = []
for sku in raw.get("skus") or []:
sku_price = sku.get("price") or {}
sku_rows.append({
"collected_at": collected_at,
"product_id": product_id,
"region": raw.get("region"),
"sku_id": as_text(sku.get("sku_id") or sku.get("id")),
"options": option_label(sku.get("options")),
"currency": sku_price.get("currency") or price.get("currency"),
"sale_price": sku_price.get("sale_price"),
"original_price": sku_price.get("original_price"),
"available_quantity": sku.get("available_quantity"),
"in_stock": sku.get("in_stock"),
"image_url": sku.get("image_url") or sku.get("image"),
})
return product_row, sku_rows
Keeping a product-level price and a SKU-level price is intentional. A displayed product price does not automatically describe every variant.
Write stable CSV files
Declare column order explicitly. Relying on whatever keys happen to appear in the first object makes exports brittle when optional fields change.
PRODUCT_FIELDS = [
"collected_at", "product_id", "region", "name", "seller_id",
"currency", "sale_price", "original_price", "available_quantity",
"in_stock", "rating", "review_count", "sold_count",
]
SKU_FIELDS = [
"collected_at", "product_id", "region", "sku_id", "options",
"currency", "sale_price", "original_price", "available_quantity",
"in_stock", "image_url",
]
def write_csv(path, fields, rows):
with open(path, "w", newline="", encoding="utf-8") as output:
writer = csv.DictWriter(output, fieldnames=fields)
writer.writeheader()
writer.writerows(rows)
with open("product-response.json", encoding="utf-8") as source:
raw = json.load(source)
collected_at = datetime.now(timezone.utc).isoformat()
product_row, sku_rows = normalize_product(raw, collected_at)
write_csv("products.csv", PRODUCT_FIELDS, [product_row])
write_csv("skus.csv", SKU_FIELDS, sku_rows)
print(f"wrote 1 product row and {len(sku_rows)} SKU rows")
The script works with zero SKU rows: skus.csv still gets a header. That output says the response contained no normalized variants; it does not claim the product has no variants in every market.
Preserve price context
Never compare price without carrying its currency and region. A numeric value alone cannot tell you whether two offers are equivalent. If you need a numeric type for calculation, create a separate decimal field and preserve the source string.
Apply the same caution to sold_count. The page field does not provide a shared recent reporting window, an order ledger, refunds, revenue, or GMV. Store it as observed rather than turning it into a sales-velocity metric.
Aggregate stock and SKU availability also answer different questions. Do not fill a missing SKU quantity with the product aggregate. Store absence as unknown and investigate it separately.
Validate the transformation
Before sending the files to a spreadsheet or warehouse, check:
- Every ID column is configured as text.
- Every SKU row has the same product ID and region as its parent observation.
- Currency travels with each monetary value.
- Empty cells are not automatically converted to zero.
- Option pairs remain readable and the raw option array is still available in the source JSON.
- Product and SKU counts reconcile with the response you processed.
- The collection timestamp is UTC and belongs to the same raw snapshot.
For managed collection, Scrapeless exposes this actor through Scraping API. The API supplies the snapshot; scheduling, transformation, and history remain application responsibilities.
Conclusion
Flattening TikTok Shop JSON is primarily a modeling task. Keep product and SKU entities separate, preserve IDs as strings, carry region and currency through every relevant row, and leave missing values unknown. That structure remains usable for one-time research and for later snapshot comparisons.
FAQ
Why not write one row per product?
A single row cannot represent multiple option combinations cleanly. Product and SKU tables preserve their different levels of detail.
Should an absent SKU array be treated as an error?
No. Write the product row and an empty SKU file, then keep the collection status and raw response for review.
Can sold_count be used as recent sales velocity?
Not from this response alone. It does not define a shared recent window or provide an order ledger.
Is collecting public Shop data legal?
Legality depends on the market, purpose, access method, fields, and applicable terms. Collect only what the task needs and obtain legal advice for the intended use.
Disclaimer: This article is for technical education and does not provide legal advice. Follow applicable laws, platform terms, and organizational policies when collecting or using public web data.

Top comments (0)