Build a competitor price position report

Last updated · Changelog · OpenAPI spec · Markdown

For each of your products, find the lowest price among other retailers and where your own price ranks. The input file has identifier and my_price columns. The report compares the lowest new-condition price per retailer, counts only prices seen in the last 7 days, and leaves out third-party marketplace sellers. Set COMPETITORS to the retailers you actually compete with, so small sellers that list the same product do not set your benchmark.

StepREST endpointMCP toolCredits
Current offers for up to 100 productsGET /v1/products/offersproduct_offers3 per product with offers

Cost for N products: at most 3 × N credits per run. Run it daily for a month and that is 90 × N, so 300 products checked daily come to about 27,000 credits. Each offer's timestamp is when ShopSavvy last saw that price; the 7-day window keeps old prices out of the comparison. See Retrieving Offers for Products at All Retailers.

bash

#!/usr/bin/env bash
# Where does your price sit against every retailer? One row per product.
# Usage: SHOPSAVVY_API_KEY=... ./price-position.sh my-prices.csv position.csv
# my-prices.csv has "identifier" (barcode, ASIN, model number or URL) and "my_price" columns.
set -euo pipefail
. ./shopsavvy-call.sh

MAX_AGE_DAYS=7       # ignore prices not seen in the last week
COMPETITORS='null'   # or a JSON array of retailer names, e.g. '["Amazon","Best Buy","Walmart"]'

echo "identifier,title,my_price,lowest_price,lowest_retailer,gap_pct,rank,retailers" > "$2"
tail -n +2 "$1" | tr -d '\r' | grep -v '^$' | split -l 100 - "$SS_TMP/chunk_"
for chunk in "$SS_TMP"/chunk_*; do
  ids=$(cut -d, -f1 "$chunk" | paste -sd, - | jq -sRr 'rtrimstr("\n") | @uri')
  api GET "/products/offers?ids=$ids" > "$SS_TMP/offers.json"
  jq -r --rawfile chunk "$chunk" --argjson competitors "$COMPETITORS" --argjson maxAge "$((MAX_AGE_DAYS * 86400))" '
    ((.data // []) | map({key: .match.identifier, value: .}) | from_entries) as $products
    | $chunk | split("\n") | map(select(length > 0) | split(","))[]
    | .[0] as $id | (.[1] | tonumber) as $mine | $products[$id] as $p
    # Lowest recent new-condition price per retailer: the retailer'"'"'s own price, not a marketplace seller'"'"'s.
    | [($p.offers // [])[]
        | select(.condition == "new" and .price != null and (.is_marketplace | not)
                 and (now - (.timestamp | sub("\\.[0-9]+Z$"; "Z") | fromdateiso8601)) <= $maxAge
                 and ($competitors == null or (.retailer as $r | $competitors | index($r))))]
    | group_by(.retailer) | map({retailer: .[0].retailer, price: (map(.price) | min)}) as $lowest
    | if ($lowest | length) == 0 then [$id, ($p.title // ""), $mine, "", "", "", "", 0]
      else ($lowest | min_by(.price)) as $best
        | [$id, $p.title, $mine, $best.price, $best.retailer,
           ((($mine - $best.price) / $best.price * 1000 | round) / 10),
           (1 + ($lowest | map(select(.price < $mine)) | length)), ($lowest | length)]
      end
    | @csv' "$SS_TMP/offers.json" >> "$2"
done
echo "Wrote $2"

Python

"""Where does your price sit against every retailer? One row per product.

Usage: SHOPSAVVY_API_KEY=... python price_position.py my-prices.csv position.csv
my-prices.csv has "identifier" (barcode, ASIN, model number or URL) and "my_price" columns.
"""
import csv
import sys
from datetime import datetime, timedelta, timezone

from shopsavvy_call import call

MAX_AGE = timedelta(days=7)  # ignore prices not seen in the last week
COMPETITORS = None  # or a set of retailer names to compare against only those, e.g. {"Amazon", "Best Buy", "Walmart"}


def main(source, destination):
    with open(source, newline="") as f:
        mine = {row["identifier"].strip(): float(row["my_price"]) for row in csv.DictReader(f) if row["identifier"].strip()}
    identifiers = list(mine)
    products = {}
    for start in range(0, len(identifiers), 100):
        response = call("GET", "/products/offers", not_found_ok=True, params={"ids": ",".join(identifiers[start:start + 100])})
        if response.status_code == 404:
            continue  # none of these matched: nothing charged
        for product in response.json()["data"]:
            products[product["match"]["identifier"]] = product
    with open(destination, "w", newline="") as f:
        out = csv.writer(f)
        out.writerow(["identifier", "title", "my_price", "lowest_price", "lowest_retailer", "gap_pct", "rank", "retailers"])
        for identifier, my_price in mine.items():
            product = products.get(identifier)
            # Lowest recent new-condition price per retailer.
            cutoff = datetime.now(timezone.utc) - MAX_AGE
            lowest = {}
            for offer in (product or {}).get("offers", []):
                seen = datetime.fromisoformat(offer["timestamp"].replace("Z", "+00:00"))
                if (offer.get("condition") == "new" and offer.get("price") is not None and seen >= cutoff
                        and not offer.get("is_marketplace")  # the retailer's own price, not a third-party seller's
                        and (COMPETITORS is None or offer["retailer"] in COMPETITORS)):
                    lowest[offer["retailer"]] = min(offer["price"], lowest.get(offer["retailer"], offer["price"]))
            if not lowest:
                out.writerow([identifier, (product or {}).get("title", ""), my_price, "", "", "", "", 0])
                continue
            retailer, price = min(lowest.items(), key=lambda item: item[1])
            rank = 1 + sum(1 for p in lowest.values() if p < my_price)
            out.writerow([identifier, product["title"], my_price, price, retailer,
                          round((my_price - price) / price * 100, 1), rank, len(lowest)])
    print(f"Wrote {destination}")


if __name__ == "__main__":
    main(sys.argv[1], sys.argv[2])

TypeScript

// Where does your price sit against every retailer? One row per product.
// Usage: SHOPSAVVY_API_KEY=... npx tsx price-position.ts my-prices.csv position.csv
// my-prices.csv has "identifier" (barcode, ASIN, model number or URL) and "my_price" columns.
import { readFileSync, writeFileSync } from 'node:fs'
import { call, csvLine } from './shopsavvy-call'

const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000 // ignore prices not seen in the last week
const COMPETITORS: Set<string> | null = null // or e.g. new Set(['Amazon', 'Best Buy', 'Walmart']) to compare against those only

async function main(source: string, destination: string) {
  const mine = new Map<string, number>()
  for (const line of readFileSync(source, 'utf8').split(/\r?\n/).slice(1)) {
    const [identifier, myPrice] = line.split(',').map((field) => field.trim())
    if (identifier) mine.set(identifier, Number(myPrice))
  }
  const identifiers = [...mine.keys()]
  const products = new Map<string, any>()
  for (let start = 0; start < identifiers.length; start += 100) {
    const ids = encodeURIComponent(identifiers.slice(start, start + 100).join(','))
    const response = await call('GET', `/products/offers?ids=${ids}`, { notFoundOk: true })
    if (response.status === 404) continue // none of these matched: nothing charged
    for (const product of (await response.json()).data) products.set(product.match.identifier, product)
  }
  let csv = csvLine(['identifier', 'title', 'my_price', 'lowest_price', 'lowest_retailer', 'gap_pct', 'rank', 'retailers'])
  for (const [identifier, myPrice] of mine) {
    const product = products.get(identifier)
    // Lowest recent new-condition price per retailer.
    const lowest = new Map<string, number>()
    for (const offer of product?.offers ?? []) {
      const recent = Date.now() - Date.parse(offer.timestamp) <= MAX_AGE_MS
      // The retailer's own price (not a third-party marketplace seller), optionally only your competitors.
      const wanted = !offer.is_marketplace && (COMPETITORS === null || COMPETITORS.has(offer.retailer))
      if (offer.condition === 'new' && offer.price != null && recent && wanted) lowest.set(offer.retailer, Math.min(offer.price, lowest.get(offer.retailer) ?? offer.price))
    }
    if (lowest.size === 0) {
      csv += csvLine([identifier, product?.title ?? '', myPrice, '', '', '', '', 0])
      continue
    }
    const [retailer, price] = [...lowest].reduce((best, entry) => (entry[1] < best[1] ? entry : best))
    const rank = 1 + [...lowest.values()].filter((p) => p < myPrice).length
    csv += csvLine([identifier, product.title, myPrice, price, retailer, Math.round((myPrice - price) / price * 1000) / 10, rank, lowest.size])
  }
  writeFileSync(destination, csv)
  console.log(`Wrote ${destination}`)
}

main(process.argv[2], process.argv[3])
Was this page helpful?