This commit is contained in:
David Rice
2026-04-29 20:33:17 +01:00
parent 2134b14ef6
commit 8743367e73
3 changed files with 22 additions and 72 deletions

16
.env Normal file
View File

@@ -0,0 +1,16 @@
# ── DigiKey ────────────────────────────────────────────────────────────────────
# Register at https://developer.digikey.com/ → Create an organisation → Add a
# production app → copy the Client ID and Client Secret below.
DIGIKEY_CLIENT_ID=LnHz9vqQR4wSVglAAar6kkGseMagstWCVlF14F7t1CKZhnz5
DIGIKEY_CLIENT_SECRET=93sQGOX3IXjlruhQfHDLBo0E98Wwyo63OCpwiZGKS3xZHkvmmeuQTJLtCnNMGjXM
# ── Mouser ─────────────────────────────────────────────────────────────────────
# Register at https://www.mouser.com/api-hub/ → Apply for a Search API key.
MOUSER_API_KEY=3afbcf04-b3fb-4d3d-8d0e-a21311b744f4
# ── Farnell / Element14 ────────────────────────────────────────────────────────
# Register at https://partner.element14.com/ → My Account → Manage API Keys.
# FARNELL_STORE controls which regional store is queried for pricing.
# Examples: fr.farnell.com de.farnell.com uk.farnell.com ie.farnell.com
FARNELL_API_KEY=3e4h2bnkdnwgzwcn6sqzvegf
FARNELL_STORE=fr.farnell.com

View File

@@ -15,6 +15,3 @@ MOUSER_API_KEY=
FARNELL_API_KEY=
FARNELL_STORE=fr.farnell.com
# ── RS Components ──────────────────────────────────────────────────────────────
# Register at https://developers.rs-online.com/ → Create an application.
RS_API_KEY=

View File

@@ -11,7 +11,6 @@ For every unique (Manufacturer, MPN) pair found, the script queries:
- DigiKey (v4 API developer.digikey.com)
- Mouser (v1 API mouser.com/api-hub)
- Farnell (Element14 API partner.element14.com)
- RS Components (v2 API developers.rs-online.com)
All prices are converted to EUR at quantity 1000 using live FX rates
from Frankfurter (api.frankfurter.app free, no key required).
@@ -490,62 +489,6 @@ class FarnellClient:
return best
class RSClient:
"""RS Components API v2. Register at developers.rs-online.com."""
_URL = "https://api.rs-online.com/searchProducts/v2/search"
def __init__(self) -> None:
self.api_key = os.getenv("RS_API_KEY", "")
@property
def configured(self) -> bool:
return bool(self.api_key)
def get_price_eur(self, manufacturer: str, mpn: str) -> Optional[float]:
if not self.configured:
return None
try:
r = requests.get(
self._URL,
params={
"term": mpn,
"fields": "prices,partNumber,manufacturer,manufacturerPartNumber",
"size": 5,
},
headers={"apiKey": self.api_key},
timeout=15,
)
r.raise_for_status()
products = r.json().get("products", [])
for product in products:
pn = product.get("manufacturerPartNumber", "")
if str(pn).lower() == mpn.lower():
p = self._price_at_qty(product)
if p is not None:
return p
if products:
return self._price_at_qty(products[0])
except Exception as exc:
log.debug(f"RS error [{mpn}]: {exc}")
return None
@staticmethod
def _price_at_qty(product: dict) -> Optional[float]:
currency = product.get("priceCurrency", "EUR")
best: Optional[float] = None
for pb in product.get("prices", []):
try:
qty = int(pb.get("from") or pb.get("quantity") or 0)
price = float(pb.get("price") or pb.get("unitPrice") or 0)
if qty <= QUANTITY and price > 0:
best = to_eur(price, currency)
except (ValueError, TypeError):
continue
return best
# ── Result dataclass ───────────────────────────────────────────────────────────
@dataclass
@@ -555,17 +498,16 @@ class PartResult:
digikey_eur: Optional[float] = None
mouser_eur: Optional[float] = None
farnell_eur: Optional[float] = None
rs_eur: Optional[float] = None
@property
def average_eur(self) -> Optional[float]:
vals = [v for v in (self.digikey_eur, self.mouser_eur, self.farnell_eur, self.rs_eur)
vals = [v for v in (self.digikey_eur, self.mouser_eur, self.farnell_eur)
if v is not None]
return round(sum(vals) / len(vals), 5) if vals else None
def sources_found(self) -> int:
return sum(v is not None for v in
(self.digikey_eur, self.mouser_eur, self.farnell_eur, self.rs_eur))
(self.digikey_eur, self.mouser_eur, self.farnell_eur))
# ── API lookup with caching ────────────────────────────────────────────────────
@@ -577,7 +519,6 @@ def lookup(
dk: DigiKeyClient,
mu: MouserClient,
fa: FarnellClient,
rs: RSClient,
) -> PartResult:
cached = cache.get(manufacturer, mpn)
if cached:
@@ -588,20 +529,17 @@ def lookup(
digikey_eur=cached.get("digikey_eur"),
mouser_eur=cached.get("mouser_eur"),
farnell_eur=cached.get("farnell_eur"),
rs_eur=cached.get("rs_eur"),
)
result = PartResult(manufacturer=manufacturer, mpn=mpn)
result.digikey_eur = dk.get_price_eur(manufacturer, mpn); time.sleep(REQUEST_DELAY)
result.mouser_eur = mu.get_price_eur(manufacturer, mpn); time.sleep(REQUEST_DELAY)
result.farnell_eur = fa.get_price_eur(manufacturer, mpn); time.sleep(REQUEST_DELAY)
result.rs_eur = rs.get_price_eur(manufacturer, mpn)
result.farnell_eur = fa.get_price_eur(manufacturer, mpn)
cache.put(manufacturer, mpn, {
"digikey_eur": result.digikey_eur,
"mouser_eur": result.mouser_eur,
"farnell_eur": result.farnell_eur,
"rs_eur": result.rs_eur,
})
return result
@@ -692,9 +630,8 @@ def main() -> None:
dk = DigiKeyClient()
mu = MouserClient()
fa = FarnellClient()
rs = RSClient()
active = [n for n, c in [("DigiKey", dk), ("Mouser", mu), ("Farnell", fa), ("RS", rs)]
active = [n for n, c in [("DigiKey", dk), ("Mouser", mu), ("Farnell", fa)]
if c.configured]
if active:
log.info(f"Configured APIs: {', '.join(active)}")
@@ -713,9 +650,9 @@ def main() -> None:
total = len(parts)
for i, (mfr, mpn) in enumerate(sorted(parts), 1):
log.info(f"[{i:>4}/{total}] {mfr or '(no mfr)':35s} {mpn}")
r = lookup(mfr, mpn, cache, dk, mu, fa, rs)
r = lookup(mfr, mpn, cache, dk, mu, fa)
tag = f"{r.average_eur:.4f}" if r.average_eur is not None else ""
log.info(f" avg {tag} ({r.sources_found()}/4 sources)")
log.info(f" avg {tag} ({r.sources_found()}/3 sources)")
price_lookup[_part_key(mfr, mpn)] = r.average_eur
write_back(file_map, price_lookup)