Changes
This commit is contained in:
16
.env
Normal file
16
.env
Normal 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
|
||||||
@@ -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=
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user