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_API_KEY=
FARNELL_STORE=fr.farnell.com 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) - DigiKey (v4 API developer.digikey.com)
- Mouser (v1 API mouser.com/api-hub) - Mouser (v1 API mouser.com/api-hub)
- Farnell (Element14 API partner.element14.com) - 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 All prices are converted to EUR at quantity 1000 using live FX rates
from Frankfurter (api.frankfurter.app free, no key required). from Frankfurter (api.frankfurter.app free, no key required).
@@ -490,62 +489,6 @@ class FarnellClient:
return best 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 ─────────────────────────────────────────────────────────── # ── Result dataclass ───────────────────────────────────────────────────────────
@dataclass @dataclass
@@ -555,17 +498,16 @@ class PartResult:
digikey_eur: Optional[float] = None digikey_eur: Optional[float] = None
mouser_eur: Optional[float] = None mouser_eur: Optional[float] = None
farnell_eur: Optional[float] = None farnell_eur: Optional[float] = None
rs_eur: Optional[float] = None
@property @property
def average_eur(self) -> Optional[float]: 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] if v is not None]
return round(sum(vals) / len(vals), 5) if vals else None return round(sum(vals) / len(vals), 5) if vals else None
def sources_found(self) -> int: def sources_found(self) -> int:
return sum(v is not None for v in 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 ──────────────────────────────────────────────────── # ── API lookup with caching ────────────────────────────────────────────────────
@@ -577,7 +519,6 @@ def lookup(
dk: DigiKeyClient, dk: DigiKeyClient,
mu: MouserClient, mu: MouserClient,
fa: FarnellClient, fa: FarnellClient,
rs: RSClient,
) -> PartResult: ) -> PartResult:
cached = cache.get(manufacturer, mpn) cached = cache.get(manufacturer, mpn)
if cached: if cached:
@@ -588,20 +529,17 @@ def lookup(
digikey_eur=cached.get("digikey_eur"), digikey_eur=cached.get("digikey_eur"),
mouser_eur=cached.get("mouser_eur"), mouser_eur=cached.get("mouser_eur"),
farnell_eur=cached.get("farnell_eur"), farnell_eur=cached.get("farnell_eur"),
rs_eur=cached.get("rs_eur"),
) )
result = PartResult(manufacturer=manufacturer, mpn=mpn) result = PartResult(manufacturer=manufacturer, mpn=mpn)
result.digikey_eur = dk.get_price_eur(manufacturer, mpn); time.sleep(REQUEST_DELAY) 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.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.farnell_eur = fa.get_price_eur(manufacturer, mpn)
result.rs_eur = rs.get_price_eur(manufacturer, mpn)
cache.put(manufacturer, mpn, { cache.put(manufacturer, mpn, {
"digikey_eur": result.digikey_eur, "digikey_eur": result.digikey_eur,
"mouser_eur": result.mouser_eur, "mouser_eur": result.mouser_eur,
"farnell_eur": result.farnell_eur, "farnell_eur": result.farnell_eur,
"rs_eur": result.rs_eur,
}) })
return result return result
@@ -692,9 +630,8 @@ def main() -> None:
dk = DigiKeyClient() dk = DigiKeyClient()
mu = MouserClient() mu = MouserClient()
fa = FarnellClient() 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 c.configured]
if active: if active:
log.info(f"Configured APIs: {', '.join(active)}") log.info(f"Configured APIs: {', '.join(active)}")
@@ -713,9 +650,9 @@ def main() -> None:
total = len(parts) total = len(parts)
for i, (mfr, mpn) in enumerate(sorted(parts), 1): for i, (mfr, mpn) in enumerate(sorted(parts), 1):
log.info(f"[{i:>4}/{total}] {mfr or '(no mfr)':35s} {mpn}") 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 "" 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 price_lookup[_part_key(mfr, mpn)] = r.average_eur
write_back(file_map, price_lookup) write_back(file_map, price_lookup)