File size: 20,323 Bytes
18a82fb | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 | import requests
from bs4 import BeautifulSoup
import pandas as pd
import re
from urllib.parse import urljoin, urlparse
import time
import json
import os
from typing import List, Optional
from dataclasses import dataclass, asdict
import openpyxl
from concurrent.futures import ThreadPoolExecutor, as_completed
import threading
from pathlib import Path
@dataclass
class DesignObject:
name: str
year: str
classification: str
dimension: str
makers: List[str]
image_urls: List[str]
country: str
price: Optional[str] = None
popularity: Optional[str] = None
source: Optional[str] = None
class DatamathCompleteScraper:
def __init__(self):
self.base_url = "http://www.datamath.org/"
self.session = requests.Session()
self.session.headers.update({
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
})
self.calculator_links = []
def collect_links_from_page(self, url, category_name):
"""Step 1: Collect ALL .htm links from a page"""
print(f"\nProcessing: {category_name}")
print(f"URL: {url}")
print("-" * 50)
links_data = []
try:
response = self.session.get(url, timeout=10)
if response.status_code == 200:
soup = BeautifulSoup(response.content, 'html.parser')
# Get ALL links
all_links = soup.find_all('a', href=True)
for link in all_links:
href = link['href']
link_text = link.get_text().strip()
# Skip anchors, mailto, and external links
if href.startswith('#') or href.startswith('mailto:'):
continue
if href.startswith('http') and 'datamath.org' not in href:
continue
# Get all .htm and .html files
if href.endswith('.htm') or href.endswith('.html'):
# Build full URL
if href.startswith('http'):
full_url = href
else:
full_url = urljoin(url, href)
# Extract filename
filename = os.path.basename(urlparse(full_url).path)
# Skip index/main pages
skip_files = ['index.htm', 'main.htm', 'start.htm', 'album_']
if any(skip in filename.lower() for skip in skip_files):
continue
links_data.append({
'category': category_name,
'name': link_text if link_text else filename.replace('.htm', ''),
'filename': filename,
'url': full_url
})
print(f" Found: {link_text[:50] if link_text else filename}")
print(f" Total links found: {len(links_data)}")
else:
print(f" Error: Status code {response.status_code}")
except Exception as e:
print(f" Error fetching page: {e}")
return links_data
def collect_all_links(self):
"""Step 1: Collect all calculator links from the 10 Album pages"""
print("=" * 70)
print(" STEP 1: COLLECTING ALL CALCULATOR LINKS")
print("=" * 70)
# The 10 main category pages
categories = [
{'filename': 'Album_Basic.htm', 'name': 'Basic Calculators'},
{'filename': 'Album_Desktop.htm', 'name': 'Desktop Calculators'},
{'filename': 'Album_Sci.htm', 'name': 'Scientific Calculators'},
{'filename': 'Album_Graph.htm', 'name': 'Graphing Calculators'},
{'filename': 'Album_Edu.htm', 'name': 'Educational Products'},
{'filename': 'Album_Personal.htm', 'name': 'Personal Calculators'},
{'filename': 'Album_Speech.htm', 'name': 'Speech Products'},
{'filename': 'Album_TISTUFF.htm', 'name': 'TI Stuff'},
{'filename': 'Album_Others.htm', 'name': 'Other Brands'},
{'filename': 'Album_Related.htm', 'name': 'Related Products'},
]
all_links = []
# Process each category
for category in categories:
url = urljoin(self.base_url, category['filename'])
links = self.collect_links_from_page(url, category['name'])
all_links.extend(links)
time.sleep(0.5) # Be polite
# Remove duplicates based on URL
unique_links = []
seen_urls = set()
for link in all_links:
if link['url'] not in seen_urls:
unique_links.append(link)
seen_urls.add(link['url'])
print("\n" + "=" * 70)
print(f" LINKS COLLECTION SUMMARY")
print("=" * 70)
print(f"Total links collected: {len(all_links)}")
print(f"Unique links: {len(unique_links)}")
self.calculator_links = unique_links
return unique_links
def scrape_calculator_page(self, url):
"""Step 2: Scrape a single calculator page for DesignObject data"""
try:
response = self.session.get(url, timeout=10)
if response.status_code != 200:
return None
soup = BeautifulSoup(response.content, 'html.parser')
all_text = soup.get_text()
# Initialize DesignObject fields
name = ""
year = ""
dimension = ""
country = ""
image_urls = []
# 1. Extract NAME from title
title = soup.find('title')
if title:
title_text = title.get_text().strip()
# Remove "Texas Instruments" or "DATAMATH" prefix
name = title_text.replace('Texas Instruments', '').replace('DATAMATH', '').strip()
# Clean up common patterns
name = re.sub(r'^\s*-\s*', '', name) # Remove leading dash
name = name.strip()
# If no name from title, try to get from URL
if not name:
filename = os.path.basename(urlparse(url).path)
name = filename.replace('.htm', '').replace('_', ' ').replace('-', ' ')
# 2. Extract YEAR (full Date of manufacture text)
# Look for "Date of manufacture" first
manufacture_pattern = r'Date of manufacture:\s*([^\n|]+)'
match = re.search(manufacture_pattern, all_text, re.IGNORECASE)
if match:
year = match.group(1).strip()
# Clean up
year = re.sub(r'\s+', ' ', year) # Remove extra spaces
# If no manufacture date, try introduction date as fallback
if not year:
intro_pattern = r'Date of introduction:\s*([^\n|]+)'
match = re.search(intro_pattern, all_text, re.IGNORECASE)
if match:
year = match.group(1).strip()
year = re.sub(r'\s+', ' ', year)
# 3. Extract DIMENSION (Physical Size, not Display size)
# Make sure we get "Size:" not "Display size:"
# Use negative lookbehind to exclude "Display size:"
size_pattern = r'(?<!Display\s)Size:\s*([^\n|]+)'
match = re.search(size_pattern, all_text, re.IGNORECASE)
if match:
dimension = match.group(1).strip()
# Clean up dimension text
dimension = re.sub(r'\s+', ' ', dimension) # Remove extra spaces
# Keep the full dimension text (inches and mm)
# 4. Extract COUNTRY from Origin of manufacture
origin_pattern = r'Origin of manufacture:\s*([^\n|]+)'
match = re.search(origin_pattern, all_text, re.IGNORECASE)
if match:
country = match.group(1).strip()
# Clean up country text
country = re.sub(r'\s+', ' ', country) # Remove extra spaces
# Remove any parenthetical info
country = re.sub(r'\([^)]*\)', '', country).strip()
# Common mappings
country_map = {
'USA': 'United States',
'US': 'United States',
'Taiwan (C)': 'Taiwan',
'Taiwan (I)': 'Taiwan',
'Taiwan': 'Taiwan',
'Japan': 'Japan',
'Thailand': 'Thailand',
'China': 'China',
'Italy': 'Italy',
'Brazil': 'Brazil',
'Malaysia': 'Malaysia',
'Philippines': 'Philippines',
}
for key, value in country_map.items():
if key.lower() in country.lower():
country = value
break
# 5. Extract IMAGE URLs (front calculator image only)
# Look for the main calculator image, not banners or icons
images = soup.find_all('img')
for img in images:
src = img.get('src', '')
if src:
src_lower = src.lower()
# Skip navigation/logo/banner images
skip_patterns = ['logo', 'button', 'arrow', 'home', 'mail', 'icon',
'banner', 'pdf.gif', 'new.gif', 'hot.gif']
if any(skip in src_lower for skip in skip_patterns):
continue
# Look for calculator images
# Prefer images in IMAGES folder or containing the model name
if any(ext in src_lower for ext in ['.jpg', '.jpeg', '.gif', '.png']):
# Check if it's in IMAGES folder (main calculator images are usually here)
if 'images/' in src_lower or 'IMAGES/' in src:
# Build full URL
if src.startswith('http'):
full_url = src
else:
full_url = urljoin(url, src)
image_urls = [full_url]
break # Found the right image
# If no IMAGES folder image yet, check if filename contains calculator model
elif not image_urls:
# Extract model name/number from the page name
page_name = os.path.basename(urlparse(url).path).replace('.htm', '')
if page_name.lower() in src_lower:
if src.startswith('http'):
full_url = src
else:
full_url = urljoin(url, src)
image_urls = [full_url]
# Don't break, keep looking for better match in IMAGES folder
# Create DesignObject
design_object = DesignObject(
name=name,
year=year, # Now contains full date text like "mth 08 year 1972"
classification="calculator",
dimension=dimension,
makers=[], # Empty list as requested
image_urls=image_urls,
country=country,
price=None,
popularity=None,
source="http://www.datamath.org/"
)
return design_object
except Exception as e:
print(f" Error scraping {url}: {e}")
return None
def scrape_all_calculators(self, limit=None, max_workers=10):
"""Step 2: Scrape all calculator pages for design data using parallel processing"""
print("\n" + "=" * 70)
print(" STEP 2: SCRAPING CALCULATOR DATA (PARALLEL)")
print("=" * 70)
if not self.calculator_links:
print("No links to scrape!")
return []
# Apply limit if specified
links_to_scrape = self.calculator_links[:limit] if limit else self.calculator_links
if limit:
print(f"Limiting to first {limit} links for testing")
print(f"\nStarting to scrape {len(links_to_scrape)} calculator pages using {max_workers} threads...")
print("-" * 70)
design_objects = []
successful = 0
failed = 0
completed = 0
# Thread-safe counter lock
counter_lock = threading.Lock()
def scrape_single_calculator(link_info):
"""Helper function to scrape a single calculator page"""
nonlocal successful, failed, completed
url = link_info['url']
name = link_info['name']
category = link_info['category']
try:
design_obj = self.scrape_calculator_page(url)
with counter_lock:
completed += 1
progress_pct = (completed / len(links_to_scrape)) * 100
if design_obj and design_obj.name:
successful += 1
print(f"[{completed:4d}/{len(links_to_scrape)}] ({progress_pct:5.1f}%) OK {name[:40]:<40} ({category})")
return design_obj
else:
failed += 1
print(f"[{completed:4d}/{len(links_to_scrape)}] ({progress_pct:5.1f}%) FAIL {name[:40]}")
return None
except Exception as e:
with counter_lock:
completed += 1
progress_pct = (completed / len(links_to_scrape)) * 100
failed += 1
print(f"[{completed:4d}/{len(links_to_scrape)}] ({progress_pct:5.1f}%) ERR {name[:40]} - {str(e)[:20]}")
return None
# Use ThreadPoolExecutor for parallel processing
with ThreadPoolExecutor(max_workers=max_workers) as executor:
# Submit all tasks
future_to_link = {
executor.submit(scrape_single_calculator, link_info): link_info
for link_info in links_to_scrape
}
# Collect results as they complete
for future in as_completed(future_to_link):
result = future.result()
if result:
design_objects.append(result)
print("\n" + "=" * 70)
print(" SCRAPING COMPLETE")
print("=" * 70)
print(f"Successful: {successful}")
print(f"Failed: {failed}")
print(f"Total processed: {completed}")
return design_objects
def save_to_xlsx(self, design_objects, filename="datamath_calculators.xlsx"):
"""Save DesignObject list to XLSX file with ||| separated lists."""
if not design_objects:
print("No data to save")
return None
# Create output path same as mobile phone museum: ../../data/metadata/
script_dir = Path(__file__).parent / '..' / '..' / 'data'
output_path = script_dir / 'metadata' / filename
# Ensure the directory exists
output_path.parent.mkdir(parents=True, exist_ok=True)
# Convert to rows for DataFrame
rows = []
for obj in design_objects:
row = {
'name': obj.name,
'year': obj.year,
'classification': obj.classification,
'dimension': obj.dimension,
'makers': '|||'.join(obj.makers),
'image_urls': '|||'.join(obj.image_urls),
'country': obj.country,
'price': obj.price or '',
'popularity': obj.popularity or '',
'source': obj.source
}
rows.append(row)
# Create DataFrame and save to Excel
df = pd.DataFrame(rows)
df.to_excel(output_path, index=False, engine='openpyxl')
print(f"\nSaved to {output_path}")
# Display sample
print("\n" + "=" * 70)
print(" SAMPLE DATA:")
print("=" * 70)
sample_df = df[['name', 'year', 'country', 'dimension']].head(20)
print(sample_df.to_string(index=False))
# Summary statistics
print("\n" + "=" * 70)
print(" SUMMARY:")
print("=" * 70)
print(f"Total calculators: {len(df)}")
print(f"With year data: {df['year'].notna().sum()}")
print(f"With country data: {df['country'].notna().sum()}")
print(f"With dimension data: {df['dimension'].notna().sum()}")
print(f"With images: {sum(1 for row in rows if row['image_urls'])}")
return df
def main():
print("=" * 70)
print(" DATAMATH CALCULATOR MUSEUM - COMPLETE SCRAPER")
print("=" * 70)
print("\nThis will:")
print("1. Collect all calculator links from 10 Album pages")
print("2. Scrape each calculator page for design data")
print("3. Save results to JSON")
print("-" * 70)
scraper = DatamathCompleteScraper()
# Options
print("\nOptions:")
print("1. Test with first 10 calculators")
print("2. Test with first 50 calculators")
print("3. Scrape ALL calculators")
choice = input("\nEnter choice (1, 2, or 3): ").strip()
# Ask for number of threads for parallel processing
print("\nParallel processing settings:")
print("- More threads = faster scraping")
print("- Too many threads may overload the server")
print("- Recommended: 5-15 threads")
while True:
try:
max_workers = int(input("\nEnter number of threads (default 10): ") or "10")
if max_workers > 0:
break
else:
print("Please enter a positive number")
except ValueError:
print("Please enter a valid number")
# Step 1: Collect all links
links = scraper.collect_all_links()
if not links:
print("\nNo links found! Exiting.")
return
# Step 2: Scrape calculator data based on choice
if choice == '1':
print(f"\nThis will scrape the first 10 calculators using {max_workers} threads.")
design_objects = scraper.scrape_all_calculators(limit=10, max_workers=max_workers)
elif choice == '2':
print(f"\nThis will scrape the first 50 calculators using {max_workers} threads.")
design_objects = scraper.scrape_all_calculators(limit=50, max_workers=max_workers)
else:
print(f"\nThis will scrape {len(links)} calculators using {max_workers} threads.")
print("This may take some time but will be much faster than sequential processing!")
confirm = input("Continue? (yes/no): ").strip().lower()
if confirm in ['yes', 'y']:
design_objects = scraper.scrape_all_calculators(max_workers=max_workers)
else:
print("Cancelled.")
return
# Step 3: Save results to Excel
if design_objects:
scraper.save_to_xlsx(design_objects)
print("\n" + "=" * 70)
print(" ALL DONE!")
print("=" * 70)
print("\nOutput file:")
print("- ../../data/metadata/datamath_calculators.xlsx (all calculator data)")
if __name__ == "__main__":
main() |