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()