Skip to content

Commit c8c5c53

Browse files
ril3yclaude
andcommitted
fix: McMaster-Carr API parsing, Bolt Depot scraping, and supplier auto-detection
- McMaster-Carr: accept HTTP 201 from subscribe, parse PascalCase API response, download authenticated images server-side (client cert required for API images) - Bolt Depot: use browser User-Agent to fix 403 rejection, remove brotli encoding - Supplier auto-detect: set supplier name on part when staging simple suppliers - Add McMaster-Carr API setup documentation with certificate instructions - Bump version to 1.1.3 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent de5f3a9 commit c8c5c53

7 files changed

Lines changed: 255 additions & 51 deletions

File tree

MakerMatrix/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,12 @@
22
MakerMatrix - Comprehensive Parts and Inventory Management System
33
"""
44

5-
__version__ = "1.1.1"
5+
__version__ = "1.1.3"
66
__schema_version__ = "1.0.0" # Database schema version
77

88
# Version history tracking
99
VERSION_INFO = {
1010
"app_version": __version__,
1111
"schema_version": __schema_version__,
12-
"description": "Bug fix release: printing, locations, modals, and scheduled backups",
12+
"description": "Bug fix release: McMaster-Carr API, Bolt Depot scraping, supplier auto-detection",
1313
}

MakerMatrix/frontend/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "makermatrix-frontend",
33
"private": true,
4-
"version": "1.1.1",
4+
"version": "1.1.3",
55
"type": "module",
66
"scripts": {
77
"dev": "vite",

MakerMatrix/frontend/src/components/parts/AddPartModal.tsx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1010,23 +1010,33 @@ const AddPartModal = ({ isOpen, onClose, onSuccess }: AddPartModalProps) => {
10101010
*/
10111011
const stageSimpleSupplier = async (supplierName: string, formattedName: string, url: string) => {
10121012
try {
1013+
const supplierLower = supplierName.toLowerCase()
1014+
1015+
// Always set the supplier name on the form so the part gets saved with it
1016+
setFormData((prev) => ({
1017+
...prev,
1018+
supplier: supplierLower,
1019+
}))
1020+
10131021
// Check if supplier already exists on backend
10141022
const existingSuppliers = await supplierService.getSuppliers()
10151023
const supplierExists = existingSuppliers.some(
1016-
(s) => s.supplier_name.toLowerCase() === supplierName.toLowerCase()
1024+
(s) => s.supplier_name.toLowerCase() === supplierLower
10171025
)
10181026

10191027
if (!supplierExists) {
10201028
// Don't save yet — just stage it so the dropdown shows it
10211029
setPendingSupplier({
1022-
name: supplierName.toLowerCase(),
1030+
name: supplierLower,
10231031
displayName: formattedName,
10241032
url: url.startsWith('http') ? url : `https://${url}`,
10251033
})
10261034
console.log(
10271035
`Staged simple supplier ${formattedName} — will be saved when part is submitted`
10281036
)
10291037
}
1038+
1039+
toast.success(`Auto-detected supplier: ${formattedName}`)
10301040
} catch (error) {
10311041
console.warn('Failed to check existing suppliers:', error)
10321042
}

MakerMatrix/suppliers/bolt_depot.py

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -133,9 +133,9 @@ def get_configuration_schema(self, **kwargs) -> List[FieldDefinition]:
133133
label="User Agent String",
134134
field_type=FieldType.TEXT,
135135
required=False,
136-
default_value="MakerMatrix/1.0 (Inventory Management System)",
136+
default_value="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
137137
description="User agent string for HTTP requests",
138-
help_text="Identifies your application to the website",
138+
help_text="Browser-like user agent for web scraping requests",
139139
),
140140
FieldDefinition(
141141
name="enable_caching",
@@ -190,13 +190,18 @@ def extract_part_number_from_url(self, url: str) -> Optional[str]:
190190

191191
def _get_headers(self) -> Dict[str, str]:
192192
"""Get headers for HTTP requests"""
193+
default_ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36"
193194
return {
194-
"User-Agent": self._config.get("user_agent", "MakerMatrix/1.0 (Inventory Management System)"),
195-
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
196-
"Accept-Language": "en-US,en;q=0.5",
195+
"User-Agent": self._config.get("user_agent", default_ua),
196+
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8",
197+
"Accept-Language": "en-US,en;q=0.9",
197198
"Accept-Encoding": "gzip, deflate",
198199
"Connection": "keep-alive",
199200
"Upgrade-Insecure-Requests": "1",
201+
"Sec-Fetch-Dest": "document",
202+
"Sec-Fetch-Mode": "navigate",
203+
"Sec-Fetch-Site": "none",
204+
"Sec-Fetch-User": "?1",
200205
}
201206

202207
def _get_http_client(self):

MakerMatrix/suppliers/mcmaster_carr.py

Lines changed: 165 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -427,7 +427,7 @@ async def _make_api_request(
427427

428428
async def _handle_response(self, response: aiohttp.ClientResponse, endpoint: str) -> Dict[str, Any]:
429429
"""Handle API response and extract data or raise appropriate errors"""
430-
if response.status == 200:
430+
if response.status in (200, 201):
431431
content_type = response.headers.get("Content-Type", "")
432432
if "application/json" in content_type:
433433
return await response.json()
@@ -569,27 +569,29 @@ async def search_parts(self, query: str, limit: int = 50) -> List[PartSearchResu
569569
logger.error(f"❌ Search failed for query '{query}': {str(e)}")
570570
return []
571571

572-
async def _subscribe_to_product(self, part_number: str, credentials: Dict[str, str] = None) -> bool:
572+
async def _subscribe_to_product(self, part_number: str, credentials: Dict[str, str] = None) -> Optional[Dict[str, Any]]:
573573
"""Subscribe to a product to enable access to its data.
574574
575575
Per McMaster API docs, the request body format is:
576576
{"URL": "https://mcmaster.com/{partNumber}"}
577+
578+
Returns the product data from the subscribe response (201), or None on failure.
577579
"""
578580
try:
579581
creds = credentials or self._credentials or {}
580582
# PUT /v1/products with URL format per Postman collection
581583
product_url = f"https://mcmaster.com/{part_number}"
582-
await self._make_api_request(
584+
response = await self._make_api_request(
583585
"products",
584586
creds,
585587
method="PUT",
586588
json_body={"URL": product_url}
587589
)
588590
logger.info(f"✅ Subscribed to product: {part_number}")
589-
return True
591+
return response
590592
except Exception as e:
591593
logger.warning(f"⚠️ Could not subscribe to product {part_number}: {str(e)}")
592-
return False
594+
return None
593595

594596
async def get_part_details(self, supplier_part_number: str) -> Optional[PartSearchResult]:
595597
"""Get detailed part information from McMaster-Carr API"""
@@ -601,16 +603,36 @@ async def get_part_details(self, supplier_part_number: str) -> Optional[PartSear
601603
logger.error("No credentials configured for part details")
602604
return None
603605

604-
# McMaster-Carr requires subscribing to products before accessing them
605-
# Try to subscribe first (may already be subscribed)
606-
await self._subscribe_to_product(supplier_part_number, credentials)
606+
# McMaster-Carr requires subscribing to products before accessing them.
607+
# The subscribe (PUT) response already contains full product data.
608+
subscribe_data = await self._subscribe_to_product(supplier_part_number, credentials)
609+
610+
if subscribe_data:
611+
result = self._parse_part_data(subscribe_data)
612+
if result:
613+
# Download authenticated image if it's an API URL
614+
if result.image_url and "api.mcmaster.com" in result.image_url:
615+
local_url = await self._download_authenticated_image(
616+
result.image_url, supplier_part_number
617+
)
618+
if local_url:
619+
result.image_url = local_url
620+
logger.info(f"✅ Retrieved details for part from subscribe response: {supplier_part_number}")
621+
return result
607622

608-
# Get product details using correct endpoint: /v1/products/{partNumber}
623+
# Fallback: try GET endpoint directly
609624
endpoint = f"products/{supplier_part_number}"
610625
response = await self._make_api_request(endpoint, credentials)
611626

612627
result = self._parse_part_data(response)
613628
if result:
629+
# Download authenticated image if it's an API URL
630+
if result.image_url and "api.mcmaster.com" in result.image_url:
631+
local_url = await self._download_authenticated_image(
632+
result.image_url, supplier_part_number
633+
)
634+
if local_url:
635+
result.image_url = local_url
614636
logger.info(f"✅ Retrieved details for part: {supplier_part_number}")
615637
return result
616638
else:
@@ -625,68 +647,172 @@ async def get_part_details(self, supplier_part_number: str) -> Optional[PartSear
625647
return None
626648

627649
def _parse_part_data(self, part_data: Dict[str, Any]) -> Optional[PartSearchResult]:
628-
"""Parse part data from McMaster-Carr API response"""
650+
"""Parse part data from McMaster-Carr API response.
651+
652+
The McMaster API returns PascalCase fields:
653+
- PartNumber, ProductStatus, ProductCategory
654+
- FamilyDescription, DetailDescription
655+
- Specifications: [{Attribute, Values}, ...]
656+
- Links: [{Key, Value}, ...]
657+
"""
629658
try:
630-
part_number = part_data.get("partNumber")
659+
# Support both PascalCase (actual API) and camelCase (legacy)
660+
part_number = part_data.get("PartNumber") or part_data.get("partNumber")
631661
if not part_number:
632662
return None
633663

634-
# Extract specifications
664+
# Build description from FamilyDescription + DetailDescription
665+
family_desc = part_data.get("FamilyDescription", "")
666+
detail_desc = part_data.get("DetailDescription", "")
667+
if family_desc and detail_desc:
668+
description = f"{family_desc}, {detail_desc}"
669+
elif family_desc:
670+
description = family_desc
671+
elif detail_desc:
672+
description = detail_desc
673+
else:
674+
description = part_data.get("description", f"McMaster-Carr Part {part_number}")
675+
676+
# Extract category
677+
category = part_data.get("ProductCategory") or part_data.get("category", "Industrial Supply")
678+
679+
# Extract specifications from array format: [{Attribute, Values}, ...]
635680
specifications = {}
636-
specs_data = part_data.get("specifications", {})
637-
for spec_name, spec_value in specs_data.items():
638-
if spec_value:
639-
specifications[spec_name] = spec_value
681+
specs_data = part_data.get("Specifications") or part_data.get("specifications")
682+
if isinstance(specs_data, list):
683+
for spec in specs_data:
684+
attr = spec.get("Attribute") or spec.get("attribute", "")
685+
values = spec.get("Values") or spec.get("values", [])
686+
if attr and values:
687+
specifications[attr] = ", ".join(str(v) for v in values)
688+
elif isinstance(specs_data, dict):
689+
for spec_name, spec_value in specs_data.items():
690+
if spec_value:
691+
specifications[spec_name] = spec_value
692+
693+
# Extract URLs from Links array: [{Key, Value}, ...]
694+
image_url = None
695+
datasheet_url = None
696+
price_link = None
697+
links = part_data.get("Links", [])
698+
base_api_url = self._config.get("api_base_url", "https://api.mcmaster.com")
699+
for link in links:
700+
key = link.get("Key", "")
701+
value = link.get("Value", "")
702+
if key == "Image" and value:
703+
# Image links are API-relative paths
704+
image_url = f"{base_api_url}/{value.lstrip('/')}" if not value.startswith("http") else value
705+
elif key == "2-D PDF" and value:
706+
datasheet_url = f"{base_api_url}/{value.lstrip('/')}" if not value.startswith("http") else value
707+
elif key == "Price" and value:
708+
price_link = value
709+
710+
# Fallback to legacy field names
711+
if not image_url:
712+
image_url = part_data.get("imageUrl")
713+
if image_url and not image_url.startswith("http"):
714+
image_url = f"https://www.mcmaster.com{image_url}"
640715

641-
# Extract pricing
716+
# Extract pricing from nested data or link
642717
pricing_data = part_data.get("pricing", {})
643718
price_text = None
644719
if pricing_data:
645720
price_value = pricing_data.get("unitPrice")
646721
unit_quantity = pricing_data.get("unitQuantity", 1)
647722
currency = pricing_data.get("currency", "USD")
648-
649723
if price_value:
650-
if unit_quantity > 1:
651-
price_text = f"{currency} {price_value} per {unit_quantity}"
652-
else:
653-
price_text = f"{currency} {price_value} each"
654-
655-
# Extract image URL
656-
image_url = part_data.get("imageUrl")
657-
if image_url and not image_url.startswith("http"):
658-
image_url = f"https://www.mcmaster.com{image_url}"
659-
660-
# Extract datasheet URL
661-
datasheet_url = part_data.get("datasheetUrl")
662-
if datasheet_url and not datasheet_url.startswith("http"):
663-
datasheet_url = f"https://www.mcmaster.com{datasheet_url}"
724+
price_text = f"{currency} {price_value} per {unit_quantity}" if unit_quantity > 1 else f"{currency} {price_value} each"
664725

665726
return PartSearchResult(
666727
supplier_part_number=part_number,
667728
manufacturer="McMaster-Carr",
668729
manufacturer_part_number=part_number,
669-
description=part_data.get("description", f"McMaster-Carr Part {part_number}"),
670-
category=part_data.get("category", "Industrial Supply"),
730+
description=description,
731+
category=category,
671732
datasheet_url=datasheet_url,
672733
image_url=image_url,
673734
stock_quantity=part_data.get("stockQuantity"),
674735
pricing=price_text,
675736
specifications=specifications if specifications else None,
676737
additional_data={
677738
"source": "mcmaster_api",
678-
"api_version": part_data.get("apiVersion"),
679-
"last_updated": part_data.get("lastUpdated"),
680-
"unit_of_measure": part_data.get("unitOfMeasure"),
681-
"minimum_order_quantity": part_data.get("minimumOrderQuantity"),
682-
"lead_time_days": part_data.get("leadTimeDays"),
739+
"product_status": part_data.get("ProductStatus"),
740+
"product_detail_url": f"https://www.mcmaster.com/{part_number}",
741+
"price_api_link": price_link,
683742
},
684743
)
685744

686745
except Exception as e:
687746
logger.error(f"❌ Failed to parse part data: {str(e)}")
688747
return None
689748

749+
async def _download_authenticated_image(self, image_api_url: str, part_number: str) -> Optional[str]:
750+
"""Download an image from the McMaster API using authenticated session and save locally.
751+
752+
McMaster image URLs are API-internal and require client cert + bearer token.
753+
The browser cannot load them directly, so we download and serve locally.
754+
755+
Returns:
756+
Local serving URL (e.g., /api/utility/get_image/{uuid}) or None on failure.
757+
"""
758+
try:
759+
from MakerMatrix.services.system.file_download_service import FileDownloadService
760+
761+
creds = self._credentials or {}
762+
token = await self._authenticate(creds)
763+
ssl_context = await self._setup_ssl_context(creds)
764+
connector = aiohttp.TCPConnector(ssl=ssl_context)
765+
timeout = aiohttp.ClientTimeout(total=self._config.get("timeout_seconds", 30))
766+
767+
headers = {
768+
"Accept": "image/*",
769+
"Authorization": f"Bearer {token}",
770+
}
771+
772+
async with aiohttp.ClientSession(connector=connector, timeout=timeout) as session:
773+
logger.info(f"Downloading authenticated image for {part_number}: {image_api_url}")
774+
async with session.get(image_api_url, headers=headers) as response:
775+
if response.status not in (200, 201):
776+
logger.warning(f"Image download failed with status {response.status}")
777+
return None
778+
779+
content_type = response.headers.get("Content-Type", "").lower()
780+
image_data = await response.read()
781+
782+
if len(image_data) < 100:
783+
logger.warning(f"Image too small ({len(image_data)} bytes), skipping")
784+
return None
785+
786+
# Determine extension from content type
787+
ext = ".png"
788+
if "jpeg" in content_type or "jpg" in content_type:
789+
ext = ".jpg"
790+
elif "gif" in content_type:
791+
ext = ".gif"
792+
elif "webp" in content_type:
793+
ext = ".webp"
794+
795+
# Generate UUID and save
796+
import uuid as uuid_mod
797+
image_uuid = str(uuid_mod.uuid5(uuid_mod.NAMESPACE_URL, image_api_url))
798+
file_service = FileDownloadService()
799+
filename = f"{image_uuid}{ext}"
800+
file_path = file_service.uploaded_images_path / filename
801+
802+
if file_path.exists():
803+
logger.info(f"McMaster image already cached: {filename}")
804+
return file_service.get_image_url(image_uuid)
805+
806+
with open(file_path, "wb") as f:
807+
f.write(image_data)
808+
809+
logger.info(f"✅ Saved McMaster image: {filename} ({len(image_data)} bytes)")
810+
return file_service.get_image_url(image_uuid)
811+
812+
except Exception as e:
813+
logger.warning(f"Failed to download McMaster image: {e}")
814+
return None
815+
690816
async def fetch_datasheet(self, supplier_part_number: str) -> Optional[str]:
691817
"""Fetch datasheet URL for a part"""
692818
try:

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
**A powerful, modern electronic parts inventory management system designed for makers, engineers, and electronics enthusiasts.**
44

5-
![Version](https://img.shields.io/badge/version-1.1.1-blue)
5+
![Version](https://img.shields.io/badge/version-1.1.3-blue)
66
![Code Quality](https://img.shields.io/badge/code%20quality-100%25%20type%20safe-brightgreen)
77
![License](https://img.shields.io/badge/license-MIT-green)
88
![GitHub stars](https://img.shields.io/github/stars/ril3y/MakerMatrix?style=social)

0 commit comments

Comments
 (0)