Initial fleet data: Air France (220) + KLM (117) aircraft

This commit is contained in:
Clément Wehrung
2026-02-04 23:03:47 +01:00
parent 8f11a1d05a
commit 0d683d3510
10 changed files with 17547 additions and 0 deletions
Executable
+229
View File
@@ -0,0 +1,229 @@
# ✈️ AF-KLM Fleet Catalog
Open source, community-maintained catalog of **Air France** and **KLM** fleets with real-time tracking of aircraft properties, WiFi connectivity, and historical changes.
---
## 📊 Fleet Overview
| Airline | Total | 📶 WiFi | 🛜 High-Speed | % Starlink |
|---------|-------|---------|---------------|------------|
| 🇫🇷 Air France | 220 | 220 (100%) | 41 | **19%** |
| 🇳🇱 KLM | 117 | 94 (80%) | 0 | **0%** |
| **Combined** | **337** | **314 (93%)** | **41** | **12%** |
> 🛜 **High-Speed** = Starlink satellite internet (50+ Mbps)
> 📶 **WiFi** = Any WiFi connectivity (low-speed or high-speed)
*Last updated: 2026-02-04*
---
## 🛫 Fleet Breakdown
### 🇫🇷 Air France (AF)
| Aircraft Type | Count |
|---------------|-------|
| A220-300 PASSENGER | 46 |
| 777-300ER | 43 |
| A350-900 | 41 |
| A320 | 29 |
| 777-200-200ER | 18 |
| A321 | 12 |
| 787-9 | 10 |
| A330-200 | 8 |
| A320 (SHARKLETS) | 6 |
| A318 | 4 |
| A319 | 3 |
| **Total** | **220** |
### 🇳🇱 KLM (KL)
| Aircraft Type | Count |
|---------------|-------|
| 737-800 | 29 |
| 777-300ER | 15 |
| 787-10 | 15 |
| 777-200-200ER | 14 |
| A321NEO | 12 |
| 787-9 | 12 |
| A330-200 | 5 |
| A330-300 | 5 |
| 737-900 | 5 |
| 737-700 | 5 |
| **Total** | **117** |
---
## 📋 Detailed Configuration
### 🇫🇷 Air France — Detailed Configuration
| Aircraft | Config | Seats | Count | 🛜 Starlink |
|----------|--------|-------|-------|-------------|
| 777-200-200ER | `J028W032Y268` | 328 | 18 | - |
| 777-300ER | `J014W028Y430` | 472 | 12 | - |
| 777-300ER | `J048W048Y273` | 369 | 8 | - |
| 777-300ER | `P004J058W028Y206` | 296 | 14 | 1/14 (7%) |
| 777-300ER | `P004J060W044Y204` | 312 | 9 | 1/9 (11%) |
| 787-9 | `J030W021Y228` | 279 | 10 | - |
| A220-300 PASSENGER | `Y148` | 148 | 46 | 12/46 (26%) |
| A318 | `Y131` | 131 | 4 | - |
| A319 | `C072Y071` | 143 | 2 | - |
| A319 | `Y142` | 142 | 1 | - |
| A320 | `C108Y066` | 174 | 22 | 2/22 (9%) |
| A320 | `Y178` | 178 | 7 | - |
| A320 (SHARKLETS) | `C108Y066` | 174 | 6 | - |
| A321 | `C082Y130` | 212 | 8 | - |
| A321 | `Y212` | 212 | 4 | - |
| A330-200 | `J036W021Y167` | 224 | 8 | 1/8 (13%) |
| A350-900 | `J034W024Y266` | 324 | 20 | 10/20 (50%) |
| A350-900 | `J048W032Y210` | 290 | 1 | 1/1 (100%) |
| A350-900 | `J048W032Y212` | 292 | 20 | 13/20 (65%) |
### 🇳🇱 KLM — Detailed Configuration
| Aircraft | Config | Seats | Count | 🛜 Starlink |
|----------|--------|-------|-------|-------------|
| 737-700 | `C036M106` | 142 | 5 | - |
| 737-800 | `C036M150` | 186 | 29 | - |
| 737-900 | `C056M132` | 188 | 5 | - |
| 777-200-200ER | `C035W024M229` | 288 | 12 | - |
| 777-200-200ER | `C035W032M219` | 286 | 2 | - |
| 777-300ER | `C035W024M322` | 381 | 15 | - |
| 787-10 | `C038W028M252` | 318 | 15 | - |
| 787-9 | `C030W021M224` | 275 | 12 | - |
| A321NEO | `C030M197` | 227 | 12 | - |
| A330-200 | `C018M246` | 264 | 5 | - |
| A330-300 | `C030M262` | 292 | 5 | - |
---
## 🚀 Quick Start
### Update the Catalog
```bash
# Set your API key
export AFKLM_API_KEY=your_api_key_here
# Update Air France
node fleet-update.js --airline AF
# Update KLM
node fleet-update.js --airline KL
# Preview changes without saving
node fleet-update.js --airline KL --dry-run
# Regenerate this README with latest stats
node generate-readme.js
```
### Using the Data
```javascript
// Load Air France fleet
const response = await fetch('https://raw.githubusercontent.com/.../airlines/AF.json');
const fleet = await response.json();
// Find all Starlink aircraft
const starlink = fleet.aircraft.filter(a => a.connectivity.wifi === 'high-speed');
console.log(`${starlink.length} aircraft with Starlink`);
// Get aircraft by type
const a350s = fleet.aircraft.filter(a => a.aircraft_type.full_name?.includes('A350'));
```
---
## 📁 Data Structure
```
af-klm/
├── airlines/
│ ├── AF.json # Air France fleet
│ └── KL.json # KLM fleet
├── schema/
│ └── aircraft.schema.json
├── fleet-update.js # Update script
└── generate-readme.js # This stats generator
```
### Aircraft Schema
```json
{
"registration": "F-HTYA",
"aircraft_type": {
"iata_code": "359",
"manufacturer": "Airbus",
"model": "A350",
"full_name": "AIRBUS A350-900"
},
"cabin": {
"physical_configuration": "J034W024Y266",
"total_seats": 324,
"classes": { "business": 34, "premium_economy": 24, "economy": 266 }
},
"connectivity": {
"wifi": "high-speed",
"wifi_provider": "Starlink",
"satellite": true
},
"tracking": {
"first_seen": "2025-01-15",
"last_seen": "2026-02-04",
"total_flights": 1250
},
"history": [
{
"timestamp": "2026-01-20",
"property": "connectivity.wifi",
"old_value": "low-speed",
"new_value": "high-speed",
"source": "airline_api"
}
]
}
```
---
## 🤝 Contributing
### Daily Updates
Community members are encouraged to run the update script daily:
1. Fork this repo
2. Set your `AFKLM_API_KEY`
3. Run `node fleet-update.js --airline AF` and `--airline KL`
4. Run `node generate-readme.js` to update stats
5. Submit a PR
### API Key
Get a free API key at [developer.airfranceklm.com](https://developer.airfranceklm.com)
---
## 📋 Schema Version
Current: **1.0.0**
---
## 📄 License
Data under [CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/)
---
Made with ✈️ by the aviation community
+9913
View File
File diff suppressed because it is too large Load Diff
+5322
View File
File diff suppressed because it is too large Load Diff
+488
View File
@@ -0,0 +1,488 @@
# Open Source Airline Fleet Catalog - Schema Proposal
> **Author:** Clément Wehrung
> **Date:** February 4, 2026
> **Status:** Draft for Discussion
> **Implementation:** See `fleet-catalog/` directory
## Overview
This document proposes a standardized JSON schema for an open source catalog of airline fleets. The goal is to track aircraft properties (WiFi, cabin configuration, IFE, etc.) across multiple airlines with a consistent format and change history.
## Design Principles
1. **One JSON file per airline** - Easy to maintain, review PRs, and avoid merge conflicts
2. **Standardized enums** - Consistent values across all airlines (e.g., WiFi types)
3. **History tracking** - Record property changes over time with timestamps
4. **Extensible** - Room for airline-specific fields without breaking the schema
5. **Machine-readable** - JSON Schema validation for data quality
## Current Implementation
The schema has been implemented with Air France data exported from the fleet database:
- **220 aircraft** with full property data
- **History tracking** for WiFi upgrades, seat config changes, etc.
- **ICAO24 hex codes** for ADS-B tracking correlation
---
## Proposed Directory Structure
```
fleet-catalog/
├── schema/
│ └── aircraft.schema.json # JSON Schema for validation
├── airlines/
│ ├── AF.json # Air France
│ ├── BA.json # British Airways
│ ├── DL.json # Delta
│ ├── LH.json # Lufthansa
│ └── ...
├── reference/
│ ├── aircraft-types.json # ICAO/IATA aircraft type codes
│ ├── wifi-providers.json # Known WiFi providers & capabilities
│ └── cabin-class-codes.json # Cabin class code mappings
└── README.md
```
---
## Schema Definition
### Root Object (Airline File)
```json
{
"schema_version": "1.0.0",
"airline": {
"iata_code": "AF",
"icao_code": "AFR",
"name": "Air France",
"country": "FR"
},
"generated_at": "2026-02-04T18:32:20.803Z",
"aircraft": [...]
}
```
### Aircraft Object
```json
{
"registration": "FHPND",
"icao24": "39bda3",
"aircraft_type": {
"iata_code": "223",
"icao_code": "A223",
"manufacturer": "Airbus",
"model": "A220",
"variant": "300",
"full_name": "AIRBUS A220-300 PASSENGER"
},
"operator": {
"sub_fleet_code": "CA",
"cabin_crew_employer": "AF",
"cockpit_crew_employer": "AF"
},
"cabin": {
"physical_configuration": "Y148",
"operational_configuration": "C008Y135",
"saleable_configuration": null,
"total_seats": 148,
"classes": {
"first": 0,
"business": 0,
"premium_economy": 0,
"economy": 148
},
"freight_configuration": "PP000LL000"
},
"connectivity": {
"wifi": "high-speed",
"wifi_provider": "Starlink",
"satellite": true,
"live_tv": false,
"power_outlets": true,
"usb_ports": true
},
"ife": {
"type": "streaming",
"personal_screens": false
},
"status": "active",
"tracking": {
"first_seen": "2025-12-20",
"last_seen": "2026-02-05",
"total_flights": 3214
},
"metadata": {
"delivery_date": null,
"msn": null,
"line_number": null,
"production_site": null,
"engine_type": null,
"aircraft_name": null,
"livery": null,
"comments": null
},
"history": [...]
}
```
---
## Standardized Enums
### `connectivity.wifi`
| Value | Description | Examples |
|-------|-------------|----------|
| `"none"` | No WiFi available | — |
| `"low-speed"` | Basic WiFi, typically < 10 Mbps | Gogo ATG, old Ku-band systems |
| `"high-speed"` | Fast WiFi, typically > 50 Mbps | Starlink, Viasat Ka-band, Gogo 2Ku |
### `connectivity.wifi_provider`
Suggested standardized provider names:
| Provider | Notes |
|----------|-------|
| `"Starlink"` | SpaceX LEO constellation |
| `"Viasat"` | Ka-band GEO satellites |
| `"Gogo 2Ku"` | Dual Ku-band antennas |
| `"Gogo ATG"` | Air-to-ground (US only) |
| `"Panasonic Ku"` | Ku-band system |
| `"Inmarsat GX"` | Global Xpress Ka-band |
| `"Anuvu"` | Formerly Global Eagle |
### `ife.type`
| Value | Description |
|-------|-------------|
| `"none"` | No IFE system |
| `"overhead"` | Shared overhead screens only |
| `"seatback"` | Personal seatback screens |
| `"streaming"` | BYOD streaming to personal devices |
| `"hybrid"` | Both seatback screens and streaming |
### `status`
| Value | Description |
|-------|-------------|
| `"active"` | Currently in service |
| `"stored"` | Temporarily stored/parked |
| `"maintenance"` | In heavy maintenance |
| `"retired"` | Permanently removed from fleet |
### Cabin Class Codes
Standard codes used in `configuration_raw`:
| Code | Class | Notes |
|------|-------|-------|
| `F` | First Class | Traditional first |
| `P` | First Class | Premium first (e.g., La Première) |
| `J` | Business Cla ss | Standard code |
| `C` | Business Class | Alternative code |
| `W` | Premium Economy | |
| `Y` | Economy | |
---
## History Tracking
Each time a property changes, append an entry to the `history` array:
```json
{
"history": [
{
"timestamp": "2026-01-15T14:30:00.000Z",
"property": "connectivity.wifi",
"old_value": "low-speed",
"new_value": "high-speed",
"source": "flight_api"
},
{
"timestamp": "2026-01-15T14:30:00.000Z",
"property": "connectivity.wifi_provider",
"old_value": "Gogo",
"new_value": "Starlink",
"source": "flight_api"
},
{
"timestamp": "2025-06-01T00:00:00.000Z",
"property": "cabin.configuration_raw",
"old_value": "Y146",
"new_value": "Y148",
"source": "manual"
}
]
}
```
### History Fields
| Field | Type | Description |
|-------|------|-------------|
| `timestamp` | ISO 8601 | When the change was detected |
| `property` | string | Dot-notation path to the changed field |
| `old_value` | any | Previous value (or `null` if new) |
| `new_value` | any | New value |
| `source` | string | How the change was detected |
### Source Values
| Value | Description |
|-------|-------------|
| `"flight_api"` | Detected via flight data API |
| `"airline_api"` | From airline's official API |
| `"manual"` | Manual update/correction |
| `"seatguru"` | SeatGuru or similar source |
| `"community"` | Community contribution |
---
## Example: Air France A220-300
```json
{
"registration": "FHPND",
"aircraft_type": {
"icao_code": "A223",
"iata_code": "223",
"manufacturer": "Airbus",
"model": "A220-300",
"variant": null
},
"cabin": {
"configuration_raw": "Y148",
"total_seats": 148,
"classes": {
"first": 0,
"business": 0,
"premium_economy": 0,
"economy": 148
}
},
"connectivity": {
"wifi": "high-speed",
"wifi_provider": "Starlink",
"live_tv": false,
"power_outlets": true,
"usb_ports": true
},
"ife": {
"type": "streaming",
"personal_screens": false
},
"status": "active",
"tracking": {
"first_seen": "2025-12-20",
"last_seen": "2026-02-05",
"total_flights": 3214
},
"history": [
{
"timestamp": "2026-01-15T14:30:00.000Z",
"property": "connectivity.wifi",
"old_value": "low-speed",
"new_value": "high-speed",
"source": "flight_api"
}
]
}
```
---
## Example: Air France 777-300ER (Multi-Class)
```json
{
"registration": "FGSQA",
"aircraft_type": {
"icao_code": "B77W",
"iata_code": "77W",
"manufacturer": "Boeing",
"model": "777-300ER",
"variant": null
},
"cabin": {
"configuration_raw": "P004J058W028Y206",
"total_seats": 296,
"classes": {
"first": 4,
"business": 58,
"premium_economy": 28,
"economy": 206
}
},
"connectivity": {
"wifi": "high-speed",
"wifi_provider": "Starlink",
"live_tv": true,
"power_outlets": true,
"usb_ports": true
},
"ife": {
"type": "seatback",
"personal_screens": true
},
"status": "active",
"tracking": {
"first_seen": "2025-12-20",
"last_seen": "2026-02-05",
"total_flights": 1137
},
"history": []
}
```
---
## Migration from Current Format
For existing data (e.g., Air France tracking), here's the field mapping:
| Current Field | New Path | Transformation |
|--------------|----------|----------------|
| `registration` | `registration` | Keep as-is (no dash) |
| `type_code` | `aircraft_type.iata_code` | Direct mapping |
| `type_name` | `aircraft_type.*` | Parse into manufacturer/model |
| `owner_airline_code` | Top-level `airline.iata_code` | Move to file level |
| `owner_airline_name` | Top-level `airline.name` | Move to file level |
| `wifi_enabled` | `connectivity.wifi` | Combine with `high_speed_wifi` |
| `high_speed_wifi` | `connectivity.wifi` | `Y``"high-speed"`, else `"low-speed"` |
| `physical_pax_configuration` | `cabin.configuration_raw` | Direct mapping |
| — | `cabin.classes` | Parse from configuration |
| `first_seen_date` | `tracking.first_seen` | Direct mapping |
| `last_seen_date` | `tracking.last_seen` | Direct mapping |
| `total_flights_tracked` | `tracking.total_flights` | Direct mapping |
### WiFi Conversion Logic
```javascript
function convertWifi(wifi_enabled, high_speed_wifi) {
if (wifi_enabled !== 'Y') return 'none';
if (high_speed_wifi === 'Y') return 'high-speed';
return 'low-speed';
}
```
### Cabin Configuration Parser
```javascript
function parseCabinConfig(config) {
// "P004J058W028Y206" → { first: 4, business: 58, premium_economy: 28, economy: 206 }
const mapping = { P: 'first', F: 'first', J: 'business', C: 'business', W: 'premium_economy', Y: 'economy' };
const classes = { first: 0, business: 0, premium_economy: 0, economy: 0 };
const regex = /([PFJCWY])(\d{3})/g;
let match;
while ((match = regex.exec(config)) !== null) {
const classKey = mapping[match[1]];
classes[classKey] += parseInt(match[2], 10);
}
return classes;
}
```
---
## Metadata Fields (for PlaneSpotters-style data)
These fields capture additional data often found on PlaneSpotters.net:
```json
{
"metadata": {
"delivery_date": "2022-03-15",
"msn": "55012",
"line_number": "1234",
"production_site": "Mirabel",
"engine_type": "PW1500G",
"aircraft_name": "Fort-de-France",
"livery": "standard",
"comments": "Olympic Games 2024 special livery"
}
}
```
### Metadata Field Descriptions
| Field | Description | Example |
|-------|-------------|---------|
| `delivery_date` | Date aircraft was delivered to airline | `2022-03-15` |
| `msn` | Manufacturer Serial Number | `55012` |
| `line_number` | Production line number | `1234` |
| `production_site` | Factory location | `Toulouse`, `Hamburg`, `Mirabel`, `Charleston` |
| `engine_type` | Engine model | `Trent XWB-84`, `GE90-115B`, `PW1500G` |
| `aircraft_name` | Given name (if any) | `Fort-de-France`, `Château de Versailles` |
| `livery` | Special paint scheme | `standard`, `SkyTeam`, `Olympic 2024` |
| `comments` | Additional notes | Free text |
### Production Sites Reference
| Manufacturer | Sites |
|--------------|-------|
| Airbus | Toulouse (France), Hamburg (Germany), Tianjin (China), Mobile (USA) |
| Boeing | Everett (USA), Renton (USA), Charleston (USA) |
| Airbus Canada | Mirabel (Canada) |
| Embraer | São José dos Campos (Brazil) |
---
## Validation
A JSON Schema file should be maintained at `schema/aircraft.schema.json` for:
- CI validation on PRs
- Editor autocomplete
- Documentation generation
---
## Open Questions
1. **Registration format:** ✅ Decided: Strip dashes (`FHPND` not `F-HPND`)
2. **ICAO24 hex codes:** ✅ Decided: Yes, include for ADS-B correlation
3. **Frequency of updates:** Real-time vs. daily snapshots?
4. **Historical snapshots:** Keep full point-in-time snapshots or just deltas?
5. **API access:** Should we provide a read-only API for querying?
6. **PlaneSpotters integration:** How to merge MSN, delivery dates, aircraft names?
---
## Implementation Status
- [x] Finalize schema based on feedback
- [x] Create JSON Schema for validation (`schema/aircraft.schema.json`)
- [x] Migrate Air France data to new format (`airlines/AF.json`)
- [x] Set up repo structure
- [x] Document contribution guidelines (`README.md`)
- [ ] Add CI for schema validation
- [ ] Add more airlines (KLM, Delta, etc.)
- [ ] Integrate PlaneSpotters metadata (MSN, delivery dates, names)
+669
View File
@@ -0,0 +1,669 @@
#!/usr/bin/env node
/**
* Air France / KLM Fleet Catalog Updater
*
* Standalone script to update AF.json or KL.json without a database.
* Fetches flights from the Air France/KLM API and updates the catalog.
*
* Usage:
* node fleet-update.js --airline AF # Update Air France
* node fleet-update.js --airline KL # Update KLM
* node fleet-update.js --airline KL --bootstrap # Build from scratch (7 days)
* node fleet-update.js --airline KL --dry-run # Preview changes
*
* Environment:
* AFKLM_API_KEY - Single API key for Air France/KLM API
* AFKLM_API_KEYS - Comma-separated API keys (for rotation)
*/
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// Airline metadata
const AIRLINES = {
AF: {
code: 'AF',
name: 'Air France',
country: 'France',
registrationPrefix: 'F-',
},
KL: {
code: 'KL',
name: 'KLM Royal Dutch Airlines',
country: 'Netherlands',
registrationPrefix: 'PH-',
},
};
// Configuration (loaded dynamically)
let CONFIG = {
apiKeys: [],
baseUrl: 'https://api.airfranceklm.com/opendata',
pageSize: 100,
requestDelay: 1100,
catalogPath: null,
airlineCode: null,
};
// Track API usage
let currentKeyIndex = 0;
let lastRequestTime = 0;
let totalRequests = 0;
// ============================================================================
// API Functions
// ============================================================================
function getApiKey() {
return CONFIG.apiKeys[currentKeyIndex];
}
function rotateKey() {
currentKeyIndex = (currentKeyIndex + 1) % CONFIG.apiKeys.length;
return getApiKey();
}
async function throttle() {
const now = Date.now();
const timeSince = now - lastRequestTime;
if (timeSince < CONFIG.requestDelay) {
await new Promise(r => setTimeout(r, CONFIG.requestDelay - timeSince));
}
lastRequestTime = Date.now();
}
async function apiRequest(endpoint, params = {}, retryCount = 0) {
await throttle();
totalRequests++;
const url = new URL(`${CONFIG.baseUrl}${endpoint}`);
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
url.searchParams.append(key, value);
}
});
// Rotate key before each request
if (CONFIG.apiKeys.length > 1 && retryCount === 0) {
rotateKey();
}
const response = await fetch(url.toString(), {
method: 'GET',
headers: {
'API-Key': getApiKey(),
'Accept': 'application/hal+json',
'Accept-Language': 'en-GB',
},
});
if (!response.ok) {
// Retry on rate limit (silently rotate key)
if ((response.status === 429 || response.status === 403) && retryCount < CONFIG.apiKeys.length - 1) {
rotateKey();
await new Promise(r => setTimeout(r, 1000));
return apiRequest(endpoint, params, retryCount + 1);
}
throw new Error(`API Error: ${response.status} ${response.statusText}`);
}
return response.json();
}
// ============================================================================
// Data Extraction
// ============================================================================
function extractAircraftFromFlight(flight, airlineCode) {
const leg = flight.flightLegs?.[0];
if (!leg?.aircraft?.registration) return null;
const aircraft = leg.aircraft;
// Filter by owner airline
if (aircraft.ownerAirlineCode !== airlineCode) return null;
return {
registration: aircraft.registration,
typeCode: aircraft.typeCode || null,
typeName: aircraft.typeName || null,
subFleetCode: aircraft.subFleetCodeId || null,
ownerAirlineCode: aircraft.ownerAirlineCode || null,
ownerAirlineName: aircraft.ownerAirlineName || null,
cabinCrewEmployer: aircraft.cabinCrewEmployer || null,
cockpitCrewEmployer: aircraft.cockpitCrewEmployer || null,
wifiEnabled: aircraft.wifiEnabled || null,
highSpeedWifi: aircraft.highSpeedWifi || null,
satelliteConnectivity: aircraft.satelliteConnectivityOnBoard || null,
physicalPaxConfiguration: aircraft.physicalPaxConfiguration || null,
};
}
function parseCabinConfig(config) {
if (!config) return { first: 0, business: 0, premium_economy: 0, economy: 0 };
// P/F = First, J/C = Business, W/S = Premium Economy, Y/M = Economy
const mapping = {
P: 'first', F: 'first',
J: 'business', C: 'business',
W: 'premium_economy', S: 'premium_economy',
Y: 'economy', M: 'economy'
};
const classes = { first: 0, business: 0, premium_economy: 0, economy: 0 };
const regex = /([PFJCWSYM])(\d{2,3})/g;
let match;
while ((match = regex.exec(config)) !== null) {
const classKey = mapping[match[1]];
if (classKey) classes[classKey] += parseInt(match[2], 10);
}
return classes;
}
function convertWifi(wifiEnabled, highSpeedWifi) {
if (wifiEnabled !== 'Y') return 'none';
if (highSpeedWifi === 'Y') return 'high-speed';
return 'low-speed';
}
function transformToSchema(raw, firstSeenDate) {
const cabinClasses = parseCabinConfig(raw.physicalPaxConfiguration);
return {
registration: raw.registration,
icao24: null,
aircraft_type: {
iata_code: raw.typeCode,
icao_code: null,
manufacturer: guessManufacturer(raw.typeName),
model: guessModel(raw.typeName),
variant: guessVariant(raw.typeName),
full_name: raw.typeName,
},
operator: {
sub_fleet_code: raw.subFleetCode,
cabin_crew_employer: raw.cabinCrewEmployer,
cockpit_crew_employer: raw.cockpitCrewEmployer,
},
cabin: {
physical_configuration: raw.physicalPaxConfiguration,
saleable_configuration: null,
total_seats: Object.values(cabinClasses).reduce((a, b) => a + b, 0) || null,
classes: cabinClasses,
freight_configuration: null,
},
connectivity: {
wifi: convertWifi(raw.wifiEnabled, raw.highSpeedWifi),
wifi_provider: raw.highSpeedWifi === 'Y' ? 'Starlink' : null,
satellite: raw.satelliteConnectivity === 'Y',
},
status: 'active',
tracking: {
first_seen: firstSeenDate,
last_seen: firstSeenDate,
total_flights: 1,
},
metadata: {
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
},
history: [],
};
}
function guessManufacturer(typeName) {
if (!typeName) return null;
if (typeName.toUpperCase().includes('AIRBUS')) return 'Airbus';
if (typeName.toUpperCase().includes('BOEING')) return 'Boeing';
if (typeName.toUpperCase().includes('EMBRAER')) return 'Embraer';
return null;
}
function guessModel(typeName) {
if (!typeName) return null;
const match = typeName.match(/A(\d{3})|(\d{3})/);
if (match) return match[1] ? `A${match[1]}` : match[2];
return null;
}
function guessVariant(typeName) {
if (!typeName) return null;
const match = typeName.match(/-(\d+)/);
return match ? match[1] : null;
}
function formatDate(date) {
return date.toISOString().split('T')[0];
}
// ============================================================================
// Fetch Flights
// ============================================================================
async function fetchFlightsForDate(dateStr, airlineCode) {
const dayStart = `${dateStr}T00:00:00Z`;
const dayEnd = `${dateStr}T23:59:59Z`;
const allFlights = [];
let pageNumber = 0;
let hasMore = true;
while (hasMore) {
try {
const response = await apiRequest('/flightstatus', {
startRange: dayStart,
endRange: dayEnd,
movementType: 'D',
timeOriginType: 'S',
timeType: 'U',
pageSize: CONFIG.pageSize,
pageNumber,
operatingAirlineCode: airlineCode,
});
const flights = response.operationalFlights || [];
allFlights.push(...flights);
const page = response.page || {};
const totalPages = page.totalPages || 1;
process.stdout.write(`\r ${dateStr}: Page ${pageNumber + 1}/${totalPages} (${allFlights.length} flights)`);
hasMore = pageNumber < (totalPages - 1);
pageNumber++;
if (pageNumber > 100) break;
} catch (error) {
if (error.message.includes('403') || error.message.includes('429')) {
console.log(`\n ⚠️ API rate limit reached after ${pageNumber} pages`);
break;
}
throw error;
}
}
process.stdout.write('\n');
return allFlights;
}
// ============================================================================
// Update Logic
// ============================================================================
function detectChanges(existing, newData, dateStr) {
const changes = [];
if (existing.connectivity?.wifi !== newData.connectivity?.wifi) {
changes.push({
timestamp: dateStr,
property: 'connectivity.wifi',
old_value: existing.connectivity?.wifi,
new_value: newData.connectivity?.wifi,
source: 'airline_api',
});
}
if (existing.connectivity?.wifi_provider !== newData.connectivity?.wifi_provider) {
changes.push({
timestamp: dateStr,
property: 'connectivity.wifi_provider',
old_value: existing.connectivity?.wifi_provider,
new_value: newData.connectivity?.wifi_provider,
source: 'airline_api',
});
}
if (existing.cabin?.physical_configuration !== newData.cabin?.physical_configuration) {
changes.push({
timestamp: dateStr,
property: 'cabin.physical_configuration',
old_value: existing.cabin?.physical_configuration,
new_value: newData.cabin?.physical_configuration,
source: 'airline_api',
});
}
if (existing.operator?.sub_fleet_code !== newData.operator?.sub_fleet_code) {
changes.push({
timestamp: dateStr,
property: 'operator.sub_fleet_code',
old_value: existing.operator?.sub_fleet_code,
new_value: newData.operator?.sub_fleet_code,
source: 'airline_api',
});
}
return changes;
}
function mergeAircraft(existing, newData, changes, dateStr) {
existing.connectivity = newData.connectivity;
existing.cabin.physical_configuration = newData.cabin.physical_configuration;
existing.cabin.total_seats = newData.cabin.total_seats;
existing.cabin.classes = newData.cabin.classes;
existing.operator = newData.operator;
existing.aircraft_type = newData.aircraft_type;
existing.tracking.last_seen = dateStr;
existing.tracking.total_flights = (existing.tracking.total_flights || 0) + 1;
existing.metadata.updated_at = new Date().toISOString();
if (changes.length > 0) {
const existingKeys = new Set(
existing.history.map(h => `${h.timestamp}|${h.property}|${h.old_value}|${h.new_value}`)
);
for (const change of changes) {
const key = `${change.timestamp}|${change.property}|${change.old_value}|${change.new_value}`;
if (!existingKeys.has(key)) {
existing.history.push(change);
}
}
}
return existing;
}
// ============================================================================
// Main
// ============================================================================
function printHelp() {
console.log(`
✈️ Air France / KLM Fleet Catalog Updater
Usage:
node fleet-update.js --airline <CODE> [options]
Required:
--airline <CODE> Airline code: AF (Air France) or KL (KLM)
Options:
--dry-run Preview changes without saving
--date <YYYY-MM-DD> Use specific date instead of today
--bootstrap Build catalog from scratch (crawl last 7 days)
--days <N> Number of days for bootstrap (default: 7)
--verbose Show detailed output
--output-changes Export changes to changes.json
--stale-days <N> Days threshold for stale aircraft (default: 30)
--help Show this help message
Environment:
AFKLM_API_KEY Single API key
AFKLM_API_KEYS Comma-separated API keys (for rotation)
Examples:
node fleet-update.js --airline AF # Update Air France
node fleet-update.js --airline KL --bootstrap # Build KLM catalog
node fleet-update.js --airline KL --dry-run # Preview KLM changes
`);
}
function getDateRange(startDate, days) {
const dates = [];
for (let i = days - 1; i >= 0; i--) {
const d = new Date(startDate);
d.setDate(d.getDate() - i);
dates.push(formatDate(d));
}
return dates;
}
async function main() {
const args = process.argv.slice(2);
if (args.includes('--help') || args.includes('-h')) {
printHelp();
process.exit(0);
}
// Parse arguments
const airlineArg = args.find((_, i) => args[i - 1] === '--airline');
const dryRun = args.includes('--dry-run');
const verbose = args.includes('--verbose') || args.includes('-v');
const outputChanges = args.includes('--output-changes');
const bootstrap = args.includes('--bootstrap');
const dateArg = args.find((_, i) => args[i - 1] === '--date');
const daysArg = args.find((_, i) => args[i - 1] === '--days');
const staleDaysArg = args.find((_, i) => args[i - 1] === '--stale-days');
const staleDays = parseInt(staleDaysArg || '30', 10);
const bootstrapDays = parseInt(daysArg || '7', 10);
// Validate airline
if (!airlineArg || !AIRLINES[airlineArg]) {
console.error('❌ Error: --airline is required (AF or KL)');
printHelp();
process.exit(1);
}
const airlineCode = airlineArg.toUpperCase();
const airline = AIRLINES[airlineCode];
// Load API keys from environment
const apiKeys = (process.env.AFKLM_API_KEYS || process.env.AFKLM_API_KEY || '').split(',').filter(k => k);
if (apiKeys.length === 0) {
console.error('❌ Error: No API key found. Set AFKLM_API_KEY or AFKLM_API_KEYS environment variable.');
process.exit(1);
}
// Configure
CONFIG.apiKeys = apiKeys;
CONFIG.airlineCode = airlineCode;
CONFIG.catalogPath = path.join(__dirname, 'airlines', `${airlineCode}.json`);
console.log(`\n✈️ ${airline.name} Fleet Catalog Updater\n`);
console.log(` 🔑 API keys loaded: ${apiKeys.length}`);
if (dryRun) {
console.log(' 🔍 DRY RUN - no changes will be saved\n');
}
// Load or create catalog
let catalog;
const catalogExists = fs.existsSync(CONFIG.catalogPath);
if (catalogExists && !bootstrap) {
console.log(`📂 Loading ${CONFIG.catalogPath}...`);
const content = fs.readFileSync(CONFIG.catalogPath, 'utf-8');
catalog = JSON.parse(content);
console.log(` Found ${catalog.aircraft_count} aircraft\n`);
} else {
if (bootstrap) {
console.log(`🚀 Bootstrap mode: Creating new catalog for ${airline.name}\n`);
} else {
console.log(`📂 No existing catalog found, creating new one\n`);
}
catalog = {
schema_version: '1.0.0',
airline: {
iata_code: airlineCode,
name: airline.name,
country: airline.country,
},
generated_at: new Date().toISOString(),
aircraft_count: 0,
aircraft: [],
};
}
// Build lookup
const aircraftByReg = new Map();
catalog.aircraft.forEach(a => aircraftByReg.set(a.registration, a));
// Determine dates to process
let datesToProcess;
if (bootstrap) {
datesToProcess = getDateRange(new Date(), bootstrapDays);
console.log(`📅 Crawling ${bootstrapDays} days: ${datesToProcess[0]}${datesToProcess[datesToProcess.length - 1]}\n`);
} else {
const targetDate = dateArg || formatDate(new Date());
datesToProcess = [targetDate];
console.log(`📅 Processing: ${targetDate}\n`);
}
// Process each date
let totalNew = 0;
let totalUpdated = 0;
let totalSeen = 0;
const allChanges = [];
const seenAircraftAll = new Map();
for (const dateStr of datesToProcess) {
console.log(`📡 Fetching ${airlineCode} flights for ${dateStr}...`);
const flights = await fetchFlightsForDate(dateStr, airlineCode);
// Extract aircraft
const seenToday = new Map();
for (const flight of flights) {
const extracted = extractAircraftFromFlight(flight, airlineCode);
if (extracted && extracted.registration) {
seenToday.set(extracted.registration, extracted);
seenAircraftAll.set(extracted.registration, { data: extracted, date: dateStr });
}
}
console.log(` ✈️ ${seenToday.size} unique ${airlineCode} aircraft\n`);
// Process
for (const [reg, rawData] of seenToday) {
const newData = transformToSchema(rawData, dateStr);
const existing = aircraftByReg.get(reg);
if (!existing) {
totalNew++;
if (verbose || bootstrap) {
console.log(` NEW: ${reg} (${rawData.typeName || 'Unknown'})`);
}
if (!dryRun) {
catalog.aircraft.push(newData);
aircraftByReg.set(reg, newData);
}
} else {
const changes = detectChanges(existing, newData, dateStr);
if (changes.length > 0) {
totalUpdated++;
if (verbose) {
console.log(` 🔄 UPDATED: ${reg}`);
changes.forEach(c => console.log(` ${c.property}: ${c.old_value}${c.new_value}`));
}
allChanges.push(...changes.map(c => ({ registration: reg, ...c })));
if (!dryRun) {
mergeAircraft(existing, newData, changes, dateStr);
}
} else {
totalSeen++;
if (!dryRun) {
existing.tracking.last_seen = dateStr;
existing.tracking.total_flights = (existing.tracking.total_flights || 0) + 1;
}
}
}
}
}
// Summary
console.log('\n' + '═'.repeat(50));
console.log('📊 Summary');
console.log('═'.repeat(50));
console.log(` New aircraft: ${totalNew}`);
console.log(` Updated aircraft: ${totalUpdated}`);
console.log(` Seen (no change): ${totalSeen}`);
console.log(` Total in catalog: ${catalog.aircraft.length}`);
console.log(` Total changes: ${allChanges.length}`);
console.log(` API requests: ${totalRequests}`);
// Stale aircraft
if (!bootstrap) {
const notSeen = catalog.aircraft.filter(a => !seenAircraftAll.has(a.registration));
const todayDate = new Date();
const staleThreshold = new Date(todayDate.getTime() - staleDays * 24 * 60 * 60 * 1000);
const staleAircraft = notSeen.filter(a => {
if (!a.tracking?.last_seen) return true;
return new Date(a.tracking.last_seen) < staleThreshold;
});
if (staleAircraft.length > 0) {
console.log(`\n⚠️ Stale aircraft (not seen in ${staleDays}+ days): ${staleAircraft.length}`);
staleAircraft.slice(0, 5).forEach(a => {
console.log(` - ${a.registration} (last: ${a.tracking?.last_seen || 'never'})`);
});
if (staleAircraft.length > 5) console.log(` ... and ${staleAircraft.length - 5} more`);
}
}
// WiFi stats
const wifiStats = { none: 0, 'low-speed': 0, 'high-speed': 0 };
catalog.aircraft.forEach(a => {
const wifi = a.connectivity?.wifi || 'none';
wifiStats[wifi] = (wifiStats[wifi] || 0) + 1;
});
const total = catalog.aircraft.length;
console.log('\n📶 Fleet WiFi Status:');
console.log(` High-speed (Starlink): ${wifiStats['high-speed']} (${total ? Math.round(wifiStats['high-speed'] / total * 100) : 0}%)`);
console.log(` Low-speed: ${wifiStats['low-speed']} (${total ? Math.round(wifiStats['low-speed'] / total * 100) : 0}%)`);
console.log(` None: ${wifiStats['none']} (${total ? Math.round(wifiStats['none'] / total * 100) : 0}%)`);
// Export changes
if (outputChanges && allChanges.length > 0) {
const changesPath = path.join(__dirname, `${airlineCode.toLowerCase()}-changes.json`);
fs.writeFileSync(changesPath, JSON.stringify({
generated_at: new Date().toISOString(),
airline: airlineCode,
changes: allChanges,
}, null, 2));
console.log(`\n📝 Changes exported to ${changesPath}`);
}
// Save
if (!dryRun && (totalNew > 0 || totalUpdated > 0 || totalSeen > 0)) {
catalog.generated_at = new Date().toISOString();
catalog.aircraft_count = catalog.aircraft.length;
catalog.aircraft.sort((a, b) => {
const typeCompare = (a.aircraft_type?.iata_code || '').localeCompare(b.aircraft_type?.iata_code || '');
if (typeCompare !== 0) return typeCompare;
return a.registration.localeCompare(b.registration);
});
// Ensure directory exists
const dir = path.dirname(CONFIG.catalogPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
console.log(`\n💾 Saving to ${CONFIG.catalogPath}...`);
fs.writeFileSync(CONFIG.catalogPath, JSON.stringify(catalog, null, 2));
console.log('✅ Done!');
} else if (dryRun) {
console.log('\n🔍 Dry run complete - no changes saved');
} else {
console.log('\n✅ No changes to save');
}
console.log();
}
main().catch(error => {
console.error(`\n❌ Error: ${error.message}`);
if (process.env.DEBUG) console.error(error.stack);
process.exit(1);
});
+393
View File
@@ -0,0 +1,393 @@
#!/usr/bin/env node
/**
* Generate README with fleet statistics
*
* Automatically updates README.md with current fleet data from JSON files.
* Run this after updating fleet data to keep stats in sync.
*
* Usage:
* node generate-readme.js
*/
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// Airline display info
const AIRLINE_INFO = {
AF: { name: 'Air France', flag: '🇫🇷', country: 'France' },
KL: { name: 'KLM', flag: '🇳🇱', country: 'Netherlands' },
};
// Load all airline data
function loadAirlines() {
const airlinesDir = path.join(__dirname, 'airlines');
const files = fs.readdirSync(airlinesDir).filter(f => f.endsWith('.json'));
const airlines = {};
for (const file of files) {
const code = file.replace('.json', '');
const content = fs.readFileSync(path.join(airlinesDir, file), 'utf-8');
airlines[code] = JSON.parse(content);
}
return airlines;
}
// Get fleet breakdown by type
function getFleetBreakdown(aircraft) {
const breakdown = {};
for (const a of aircraft) {
const typeName = a.aircraft_type?.full_name || 'Unknown';
// Simplify type name
let simpleType = typeName
.replace('AIRBUS ', '')
.replace('BOEING ', '')
.replace(' (WINGLETS) PASSENGER/BBJ1', '')
.replace(' (WINGLETS) PASSENGER/BBJ2', '')
.replace(' (WINGLETS) PASSENGER/BBJ3', '')
.replace('/200 ER', '-200ER')
.replace('-200/200 ER', '-200ER')
.trim();
breakdown[simpleType] = (breakdown[simpleType] || 0) + 1;
}
// Sort by count descending
return Object.entries(breakdown)
.sort((a, b) => b[1] - a[1]);
}
// Get WiFi stats
function getWifiStats(aircraft) {
const stats = { none: 0, 'low-speed': 0, 'high-speed': 0 };
for (const a of aircraft) {
const wifi = a.connectivity?.wifi || 'none';
stats[wifi] = (stats[wifi] || 0) + 1;
}
const total = aircraft.length;
return {
total,
none: stats.none,
lowSpeed: stats['low-speed'],
highSpeed: stats['high-speed'],
nonePercent: total ? Math.round(stats.none / total * 100) : 0,
lowSpeedPercent: total ? Math.round(stats['low-speed'] / total * 100) : 0,
highSpeedPercent: total ? Math.round(stats['high-speed'] / total * 100) : 0,
};
}
// Generate markdown table for fleet breakdown
function generateFleetTable(airlines) {
let md = '';
for (const [code, data] of Object.entries(airlines)) {
const info = AIRLINE_INFO[code] || { name: code, flag: '✈️' };
const breakdown = getFleetBreakdown(data.aircraft);
const wifi = getWifiStats(data.aircraft);
md += `### ${info.flag} ${info.name} (${code})\n\n`;
md += `| Aircraft Type | Count |\n`;
md += `|---------------|-------|\n`;
for (const [type, count] of breakdown) {
md += `| ${type} | ${count} |\n`;
}
md += `| **Total** | **${wifi.total}** |\n\n`;
}
return md;
}
// Get detailed breakdown by type and config
function getDetailedBreakdown(aircraft) {
const breakdown = {};
for (const a of aircraft) {
const typeName = a.aircraft_type?.full_name || 'Unknown';
// Simplify type name
let simpleType = typeName
.replace('AIRBUS ', '')
.replace('BOEING ', '')
.replace(' (WINGLETS) PASSENGER/BBJ1', '')
.replace(' (WINGLETS) PASSENGER/BBJ2', '')
.replace(' (WINGLETS) PASSENGER/BBJ3', '')
.replace('/200 ER', '-200ER')
.replace('-200/200 ER', '-200ER')
.trim();
const config = a.cabin?.physical_configuration || '-';
const wifi = a.connectivity?.wifi || 'none';
const seats = a.cabin?.total_seats || 0;
const key = `${simpleType}|||${config}`;
if (!breakdown[key]) {
breakdown[key] = {
type: simpleType,
config,
seats,
wifi,
count: 0,
highSpeed: 0,
};
}
breakdown[key].count++;
if (wifi === 'high-speed') {
breakdown[key].highSpeed++;
}
}
// Sort by type name, then by config (to group similar aircraft together)
return Object.values(breakdown)
.sort((a, b) => {
const typeCompare = a.type.localeCompare(b.type);
if (typeCompare !== 0) return typeCompare;
return a.config.localeCompare(b.config);
});
}
// Generate detailed fleet table per airline
function generateDetailedFleetTable(airlines) {
let md = '';
for (const [code, data] of Object.entries(airlines)) {
const info = AIRLINE_INFO[code] || { name: code, flag: '✈️' };
const breakdown = getDetailedBreakdown(data.aircraft);
md += `### ${info.flag} ${info.name} — Detailed Configuration\n\n`;
md += `| Aircraft | Config | Seats | Count | 🛜 Starlink |\n`;
md += `|----------|--------|-------|-------|-------------|\n`;
for (const item of breakdown) {
const starlinkInfo = item.highSpeed > 0
? `${item.highSpeed}/${item.count} (${Math.round(item.highSpeed / item.count * 100)}%)`
: '-';
md += `| ${item.type} | \`${item.config}\` | ${item.seats || '-'} | ${item.count} | ${starlinkInfo} |\n`;
}
md += `\n`;
}
return md;
}
// Generate WiFi summary table
function generateWifiSummary(airlines) {
let md = '| Airline | Total | 📶 WiFi | 🛜 High-Speed | % Starlink |\n';
md += '|---------|-------|---------|---------------|------------|\n';
let grandTotal = 0;
let grandWifi = 0;
let grandHighSpeed = 0;
for (const [code, data] of Object.entries(airlines)) {
const info = AIRLINE_INFO[code] || { name: code, flag: '✈️' };
const wifi = getWifiStats(data.aircraft);
const wifiTotal = wifi.lowSpeed + wifi.highSpeed;
const wifiPercent = wifi.total ? Math.round(wifiTotal / wifi.total * 100) : 0;
md += `| ${info.flag} ${info.name} | ${wifi.total} | ${wifiTotal} (${wifiPercent}%) | ${wifi.highSpeed} | **${wifi.highSpeedPercent}%** |\n`;
grandTotal += wifi.total;
grandWifi += wifiTotal;
grandHighSpeed += wifi.highSpeed;
}
const grandWifiPercent = grandTotal ? Math.round(grandWifi / grandTotal * 100) : 0;
const grandHighSpeedPercent = grandTotal ? Math.round(grandHighSpeed / grandTotal * 100) : 0;
md += `| **Combined** | **${grandTotal}** | **${grandWifi} (${grandWifiPercent}%)** | **${grandHighSpeed}** | **${grandHighSpeedPercent}%** |\n`;
return md;
}
// Generate the full README
function generateReadme(airlines) {
const now = new Date().toISOString().split('T')[0];
return `# ✈️ AF-KLM Fleet Catalog
Open source, community-maintained catalog of **Air France** and **KLM** fleets with real-time tracking of aircraft properties, WiFi connectivity, and historical changes.
---
## 📊 Fleet Overview
${generateWifiSummary(airlines)}
> 🛜 **High-Speed** = Starlink satellite internet (50+ Mbps)
> 📶 **WiFi** = Any WiFi connectivity (low-speed or high-speed)
*Last updated: ${now}*
---
## 🛫 Fleet Breakdown
${generateFleetTable(airlines)}
---
## 📋 Detailed Configuration
${generateDetailedFleetTable(airlines)}
---
## 🚀 Quick Start
### Update the Catalog
\`\`\`bash
# Set your API key
export AFKLM_API_KEY=your_api_key_here
# Update Air France
node fleet-update.js --airline AF
# Update KLM
node fleet-update.js --airline KL
# Preview changes without saving
node fleet-update.js --airline KL --dry-run
# Regenerate this README with latest stats
node generate-readme.js
\`\`\`
### Using the Data
\`\`\`javascript
// Load Air France fleet
const response = await fetch('https://raw.githubusercontent.com/.../airlines/AF.json');
const fleet = await response.json();
// Find all Starlink aircraft
const starlink = fleet.aircraft.filter(a => a.connectivity.wifi === 'high-speed');
console.log(\`\${starlink.length} aircraft with Starlink\`);
// Get aircraft by type
const a350s = fleet.aircraft.filter(a => a.aircraft_type.full_name?.includes('A350'));
\`\`\`
---
## 📁 Data Structure
\`\`\`
af-klm/
├── airlines/
│ ├── AF.json # Air France fleet
│ └── KL.json # KLM fleet
├── schema/
│ └── aircraft.schema.json
├── fleet-update.js # Update script
└── generate-readme.js # This stats generator
\`\`\`
### Aircraft Schema
\`\`\`json
{
"registration": "F-HTYA",
"aircraft_type": {
"iata_code": "359",
"manufacturer": "Airbus",
"model": "A350",
"full_name": "AIRBUS A350-900"
},
"cabin": {
"physical_configuration": "J034W024Y266",
"total_seats": 324,
"classes": { "business": 34, "premium_economy": 24, "economy": 266 }
},
"connectivity": {
"wifi": "high-speed",
"wifi_provider": "Starlink",
"satellite": true
},
"tracking": {
"first_seen": "2025-01-15",
"last_seen": "2026-02-04",
"total_flights": 1250
},
"history": [
{
"timestamp": "2026-01-20",
"property": "connectivity.wifi",
"old_value": "low-speed",
"new_value": "high-speed",
"source": "airline_api"
}
]
}
\`\`\`
---
## 🤝 Contributing
### Daily Updates
Community members are encouraged to run the update script daily:
1. Fork this repo
2. Set your \`AFKLM_API_KEY\`
3. Run \`node fleet-update.js --airline AF\` and \`--airline KL\`
4. Run \`node generate-readme.js\` to update stats
5. Submit a PR
### API Key
Get a free API key at [developer.airfranceklm.com](https://developer.airfranceklm.com)
---
## 📋 Schema Version
Current: **1.0.0**
---
## 📄 License
Data under [CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/)
---
Made with ✈️ by the aviation community
`;
}
// Main
function main() {
console.log('📊 Generating README with fleet statistics...\n');
const airlines = loadAirlines();
// Show summary
for (const [code, data] of Object.entries(airlines)) {
const info = AIRLINE_INFO[code] || { name: code };
const wifi = getWifiStats(data.aircraft);
console.log(`${info.name}: ${wifi.total} aircraft, ${wifi.highSpeed} Starlink (${wifi.highSpeedPercent}%)`);
}
// Generate and save README
const readme = generateReadme(airlines);
const readmePath = path.join(__dirname, 'README.md');
fs.writeFileSync(readmePath, readme);
console.log(`\n✅ README.md updated!`);
}
main();
Executable
+38
View File
@@ -0,0 +1,38 @@
{
"name": "fleet-catalog",
"version": "1.0.0",
"description": "Open-source catalog of airline fleets with historical tracking",
"type": "module",
"scripts": {
"update:af": "node fleet-update.js --airline AF",
"update:kl": "node fleet-update.js --airline KL",
"update:all": "node fleet-update.js --airline AF && node fleet-update.js --airline KL && node generate-readme.js",
"update:af:dry": "node fleet-update.js --airline AF --dry-run",
"update:kl:dry": "node fleet-update.js --airline KL --dry-run",
"bootstrap:af": "node fleet-update.js --airline AF --bootstrap",
"bootstrap:kl": "node fleet-update.js --airline KL --bootstrap",
"readme": "node generate-readme.js",
"validate": "node scripts/validate.js"
},
"keywords": [
"aviation",
"airlines",
"fleet",
"aircraft",
"tracking"
],
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/fleet-catalog/fleet-catalog"
},
"engines": {
"node": ">=18.0.0"
},
"files": [
"airlines/",
"schema/",
"reference/"
]
}
+75
View File
@@ -0,0 +1,75 @@
{
"$schema": "../schema/reference.schema.json",
"description": "Cabin class codes used in seat configuration strings",
"codes": [
{
"code": "P",
"class": "first",
"name": "First Class / La Première",
"notes": "Premium first class, used by Air France for La Première"
},
{
"code": "F",
"class": "first",
"name": "First Class",
"notes": "Traditional first class"
},
{
"code": "J",
"class": "business",
"name": "Business Class",
"notes": "Standard business class code"
},
{
"code": "C",
"class": "business",
"name": "Business Class",
"notes": "Alternative business class code, sometimes used for intra-European business"
},
{
"code": "W",
"class": "premium_economy",
"name": "Premium Economy",
"notes": "Premium economy class"
},
{
"code": "Y",
"class": "economy",
"name": "Economy Class",
"notes": "Standard economy class"
}
],
"parsing_notes": [
"Configuration strings follow format: [CLASS_CODE][SEAT_COUNT]",
"Seat count is typically 2-3 digits (e.g., J034, Y266, or J34, Y266)",
"Multiple classes are concatenated: P004J058W028Y206",
"Parse left-to-right, extracting each class code followed by its count"
],
"examples": [
{
"configuration": "Y148",
"parsed": { "economy": 148 },
"total": 148,
"description": "Single-class economy (e.g., A220)"
},
{
"configuration": "J034W024Y266",
"parsed": { "business": 34, "premium_economy": 24, "economy": 266 },
"total": 324,
"description": "Three-class long-haul (e.g., A350-900)"
},
{
"configuration": "P004J058W028Y206",
"parsed": { "first": 4, "business": 58, "premium_economy": 28, "economy": 206 },
"total": 296,
"description": "Four-class with La Première (e.g., 777-300ER)"
},
{
"configuration": "C108Y066",
"parsed": { "business": 108, "economy": 66 },
"total": 174,
"description": "Two-class short-haul with business (e.g., A320)"
}
]
}
+87
View File
@@ -0,0 +1,87 @@
{
"$schema": "../schema/reference.schema.json",
"description": "Known WiFi providers and their characteristics",
"providers": [
{
"id": "starlink",
"name": "Starlink",
"company": "SpaceX",
"technology": "LEO satellite",
"speed_tier": "high-speed",
"typical_speed_mbps": "50-200",
"coverage": "global",
"notes": "Low-earth orbit constellation, low latency"
},
{
"id": "viasat",
"name": "Viasat",
"company": "Viasat Inc.",
"technology": "Ka-band GEO satellite",
"speed_tier": "high-speed",
"typical_speed_mbps": "12-100",
"coverage": "regional",
"notes": "ViaSat-1, ViaSat-2, ViaSat-3 satellites"
},
{
"id": "gogo_2ku",
"name": "Gogo 2Ku",
"company": "Gogo",
"technology": "Dual Ku-band satellite",
"speed_tier": "high-speed",
"typical_speed_mbps": "15-70",
"coverage": "global",
"notes": "Dual antenna system for better coverage"
},
{
"id": "gogo_atg",
"name": "Gogo ATG",
"company": "Gogo",
"technology": "Air-to-ground",
"speed_tier": "low-speed",
"typical_speed_mbps": "3-10",
"coverage": "continental_us",
"notes": "Ground-based towers, US domestic only"
},
{
"id": "panasonic_ku",
"name": "Panasonic Ku-band",
"company": "Panasonic Avionics",
"technology": "Ku-band satellite",
"speed_tier": "low-speed",
"typical_speed_mbps": "5-20",
"coverage": "global",
"notes": "eXConnect service"
},
{
"id": "inmarsat_gx",
"name": "Inmarsat GX Aviation",
"company": "Inmarsat",
"technology": "Ka-band GEO satellite",
"speed_tier": "high-speed",
"typical_speed_mbps": "15-50",
"coverage": "global",
"notes": "Global Xpress network"
},
{
"id": "anuvu",
"name": "Anuvu",
"company": "Anuvu (formerly Global Eagle)",
"technology": "Ku-band satellite",
"speed_tier": "low-speed",
"typical_speed_mbps": "5-15",
"coverage": "regional",
"notes": "Formerly Global Eagle Entertainment"
},
{
"id": "thales_flexvue",
"name": "Thales FlexVue",
"company": "Thales",
"technology": "Ku/Ka-band satellite",
"speed_tier": "high-speed",
"typical_speed_mbps": "20-50",
"coverage": "global",
"notes": "Part of Thales InFlyt Experience"
}
]
}
+333
View File
@@ -0,0 +1,333 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/fleet-catalog/schema/aircraft.schema.json",
"title": "Airline Fleet Catalog",
"description": "Standardized schema for tracking airline fleet properties across multiple carriers",
"type": "object",
"required": ["schema_version", "airline", "generated_at", "aircraft"],
"properties": {
"schema_version": {
"type": "string",
"description": "Semantic version of the schema",
"pattern": "^\\d+\\.\\d+\\.\\d+$"
},
"airline": {
"type": "object",
"required": ["iata_code", "name"],
"properties": {
"iata_code": {
"type": "string",
"description": "2-letter IATA airline code",
"pattern": "^[A-Z0-9]{2}$"
},
"icao_code": {
"type": ["string", "null"],
"description": "3-letter ICAO airline code",
"pattern": "^[A-Z]{3}$"
},
"name": {
"type": "string",
"description": "Full airline name"
},
"country": {
"type": ["string", "null"],
"description": "ISO 3166-1 alpha-2 country code"
}
}
},
"generated_at": {
"type": "string",
"format": "date-time",
"description": "ISO 8601 timestamp when this file was generated"
},
"aircraft_count": {
"type": "integer",
"description": "Total number of aircraft in this file"
},
"aircraft": {
"type": "array",
"items": {
"$ref": "#/$defs/aircraft"
}
}
},
"$defs": {
"aircraft": {
"type": "object",
"required": ["registration"],
"properties": {
"registration": {
"type": "string",
"description": "Aircraft registration (tail number) without dashes"
},
"icao24": {
"type": ["string", "null"],
"description": "24-bit ICAO Mode-S transponder address in hexadecimal"
},
"aircraft_type": {
"$ref": "#/$defs/aircraft_type"
},
"operator": {
"$ref": "#/$defs/operator"
},
"cabin": {
"$ref": "#/$defs/cabin"
},
"connectivity": {
"$ref": "#/$defs/connectivity"
},
"ife": {
"$ref": "#/$defs/ife"
},
"status": {
"type": "string",
"enum": ["active", "stored", "maintenance", "retired"],
"description": "Current operational status"
},
"tracking": {
"$ref": "#/$defs/tracking"
},
"metadata": {
"$ref": "#/$defs/metadata"
},
"history": {
"type": "array",
"items": {
"$ref": "#/$defs/history_entry"
}
}
}
},
"aircraft_type": {
"type": "object",
"properties": {
"iata_code": {
"type": ["string", "null"],
"description": "IATA aircraft type code (e.g., 77W, 359)"
},
"icao_code": {
"type": ["string", "null"],
"description": "ICAO aircraft type designator (e.g., B77W, A359)"
},
"manufacturer": {
"type": ["string", "null"],
"description": "Aircraft manufacturer (Airbus, Boeing, Embraer, etc.)"
},
"model": {
"type": ["string", "null"],
"description": "Aircraft model (A350, 777, etc.)"
},
"variant": {
"type": ["string", "null"],
"description": "Aircraft variant (900, 300ER, etc.)"
},
"full_name": {
"type": ["string", "null"],
"description": "Full aircraft type name"
}
}
},
"operator": {
"type": "object",
"description": "Operational details specific to this aircraft",
"properties": {
"sub_fleet_code": {
"type": ["string", "null"],
"description": "Internal sub-fleet code (e.g., AB, CA, AR)"
},
"cabin_crew_employer": {
"type": ["string", "null"],
"description": "Airline code of cabin crew employer"
},
"cockpit_crew_employer": {
"type": ["string", "null"],
"description": "Airline code of cockpit crew employer"
}
}
},
"cabin": {
"type": "object",
"properties": {
"physical_configuration": {
"type": ["string", "null"],
"description": "Physical seat configuration code (e.g., J034W024Y266)"
},
"operational_configuration": {
"type": ["string", "null"],
"description": "Operational/saleable seat configuration"
},
"saleable_configuration": {
"type": ["string", "null"],
"description": "Saleable seat configuration"
},
"total_seats": {
"type": ["integer", "null"],
"description": "Total number of passenger seats"
},
"classes": {
"type": "object",
"properties": {
"first": {
"type": "integer",
"description": "Number of first class seats"
},
"business": {
"type": "integer",
"description": "Number of business class seats"
},
"premium_economy": {
"type": "integer",
"description": "Number of premium economy seats"
},
"economy": {
"type": "integer",
"description": "Number of economy seats"
}
}
},
"freight_configuration": {
"type": ["string", "null"],
"description": "Cargo hold configuration (e.g., PP008LL012)"
}
}
},
"connectivity": {
"type": "object",
"properties": {
"wifi": {
"type": "string",
"enum": ["none", "low-speed", "high-speed"],
"description": "WiFi availability and speed tier"
},
"wifi_provider": {
"type": ["string", "null"],
"description": "WiFi service provider (Starlink, Viasat, Gogo 2Ku, etc.)"
},
"satellite": {
"type": ["boolean", "null"],
"description": "Whether satellite connectivity is available"
},
"live_tv": {
"type": ["boolean", "null"],
"description": "Whether live TV is available"
},
"power_outlets": {
"type": ["boolean", "null"],
"description": "Whether AC power outlets are available"
},
"usb_ports": {
"type": ["boolean", "null"],
"description": "Whether USB charging ports are available"
}
}
},
"ife": {
"type": "object",
"description": "In-flight entertainment system",
"properties": {
"type": {
"type": ["string", "null"],
"enum": ["none", "overhead", "seatback", "streaming", "hybrid", null],
"description": "Type of IFE system"
},
"personal_screens": {
"type": ["boolean", "null"],
"description": "Whether personal seatback screens are available"
}
}
},
"tracking": {
"type": "object",
"description": "Flight tracking statistics",
"properties": {
"first_seen": {
"type": ["string", "null"],
"format": "date",
"description": "Date when aircraft was first tracked"
},
"last_seen": {
"type": ["string", "null"],
"format": "date",
"description": "Date when aircraft was last tracked"
},
"total_flights": {
"type": ["integer", "null"],
"description": "Total number of flights tracked"
}
}
},
"metadata": {
"type": "object",
"description": "Additional metadata about the aircraft",
"properties": {
"delivery_date": {
"type": ["string", "null"],
"format": "date",
"description": "Date aircraft was delivered to airline"
},
"msn": {
"type": ["string", "null"],
"description": "Manufacturer Serial Number"
},
"line_number": {
"type": ["string", "null"],
"description": "Production line number"
},
"production_site": {
"type": ["string", "null"],
"description": "Factory/production site (e.g., Toulouse, Hamburg, Mirabel)"
},
"engine_type": {
"type": ["string", "null"],
"description": "Engine model (e.g., Trent XWB-84, GE90-115B)"
},
"aircraft_name": {
"type": ["string", "null"],
"description": "Aircraft given name (e.g., 'Fort-de-France')"
},
"livery": {
"type": ["string", "null"],
"description": "Special livery or paint scheme"
},
"comments": {
"type": ["string", "null"],
"description": "Additional notes or comments"
},
"created_at": {
"type": ["string", "null"],
"format": "date-time"
},
"updated_at": {
"type": ["string", "null"],
"format": "date-time"
}
}
},
"history_entry": {
"type": "object",
"required": ["timestamp", "property"],
"properties": {
"timestamp": {
"type": "string",
"description": "Date or datetime when change was detected"
},
"property": {
"type": "string",
"description": "Dot-notation path to the changed property"
},
"old_value": {
"description": "Previous value"
},
"new_value": {
"description": "New value"
},
"source": {
"type": ["string", "null"],
"enum": ["flight_api", "airline_api", "manual", "planespotters", "community", null],
"description": "Source of the change detection"
}
}
}
}
}