Skill: fungjcode/earthquake-monitor (version 1.1.1)

--- name: earthquake-monitor description: "🌋 Real-time earthquake monitoring for China, Taiwan, and Japan. CENC/CWA/JMA data with proactive alerting. v1.1.1 - Multi-language (zh/en/ja), pinyin location matching, optimized cache" homepage: https://github.com/fungjcode/earthquake-monitor metadata: openclaw: emoji: "🌋" requires: bins: ["curl"] node: ">=18" --- # 🌋 Earthquake Monitor v1.1.1 Real-time earthquake monitoring for China (CENC), Taiwan (CWA), and Japan (JMA) with proactive alerting. ## v1.1.1 Changelog ### v1.1.1 (Security Fix) - 🔒 **Security Update** - Removed encryption for ClawHub compatibility - 📝 Added SECURITY.md documentation ### v1.1.0 Features - 🌍 **Multi-language Support** - Alert messages in Chinese, English, and Japanese - 📍 **Location Fuzzy Matching** - Supports pinyin (dali), abbreviations (DL), partial match (da) - ⚡ **Performance Optimization** - Shared cache module with auto-cleanup - ✅ **Fixed** Taiwan (CWA) data source integration - ✅ **Improved** notification deduplication logic --- ## Quick Start ```javascript // Initialize monitoring await init({ location: "大理" }) // Get latest earthquakes await getAll() // Start proactive monitoring await start() ``` --- ## Data Sources | Source | Region | Language (Alert) | Description | |--------|--------|------------------|-------------| | CENC | 🇨🇳 China | 中文 | China Earthquake Networks Center | | CWA | 🇹🇼 Taiwan | 中文 | Central Weather Administration | | JMA | 🇯🇵 Japan | 日本語 | Japan Meteorological Agency | --- ## API Reference ### init(options) Initialize configuration. ```javascript await init({ location: "dali", // City name (supports pinyin, abbreviations) distanceThreshold: 300, // Alert distance in km minMagnitude: 3.0, // Minimum magnitude language: 'zh', // Language: zh/en/ja sources: { // Toggle data sources CENC: true, JMA: true, CWA: true } }) ``` ### getAll(options) Get earthquakes from all sources. ```javascript const result = await getAll({ limit: 5 }) // Returns: { earthquakes, totalCount, nearbyEarthquakes, hasAlert, alertMessage } ``` ### getCENC(limit) Get China earthquake data. ```javascript const { earthquakes } = await getCENC(10) ``` ### getJMA(limit) Get Japan earthquake data. ```javascript const { earthquakes } = await getJMA(10) ``` ### getCWA() Get Taiwan earthquake early warning data. ```javascript const { earthquakes, isWarning } = await getCWA() ``` ### start(options) Start proactive monitoring with auto-alerts. ```javascript await start({ interval: 60000 }) // Check every 60 seconds ``` ### stop() Stop monitoring. ```javascript await stop() ``` ### config(newConfig) View or update configuration. ```javascript // View const cfg = await config() // Update await config({ language: 'en', minMagnitude: 4.0 }) ``` ### cities() List all supported cities with coordinates. ```javascript const { cities } = await cities() ``` --- ## Configuration Options | Option | Type | Default | Description | |--------|------|---------|-------------| | location | string/object | 大理 | City name or {name, latitude, longitude} | | distanceThreshold | number | 300 | Alert distance in km | | minMagnitude | number | 3.0 | Minimum earthquake magnitude | | language | string | zh | Alert language: zh/en/ja | | sources.CENC | boolean | true | Enable China data | | sources.JMA | boolean | true | Enable Japan data | | sources.CWA | boolean | true | Enable Taiwan data | | webhook | string | null | Encrypted webhook URL | --- ## Supported Cities (20+) | City | Pinyin | Abbr | Coordinates | |------|--------|------|-------------| | 大理 | dali, dal, dl | DL | 25.61°N, 100.27°E | | 北京 | beijing, bj, b | BJ | 39.90°N, 116.40°E | | 上海 | shanghai, sh, s | SH | 31.23°N, 121.47°E | | 昆明 | kunming, km, k | KM | 25.04°N, 102.71°E | | 成都 | chengdu, cd, c | CD | 30.57°N, 104.07°E | | 东京 | tokyo, dj, d | DJ | 35.68°N, 139.69°E | | ... | ... | ... | ... | ### Location Matching Examples All these return Beijing: ```javascript await init({ location: '北京' }) // Chinese await init({ location: 'beijing' }) // Full pinyin await init({ location: 'bj' }) // Abbreviation await init({ location: 'bei' }) // Partial match ``` --- ## Multi-Language Alerts Alert language is automatically selected based on earthquake source: | Source | Language | Example | |--------|----------|---------| | CENC (China) | 中文 | ⚠️ 地震预警提醒！ | | CWA (Taiwan) | 中文 | ⚠️ 地震预警提醒！ | | JMA (Japan) | 日本語 | ⚠️ 地震アラート！ | ### Manual Language Override ```javascript // Set preferred language (applies to alert format) await init({ language: 'en' }) // All alerts will be in English regardless of source ``` ### Alert Message Format ``` ⚠️ Earthquake Alert! 📍 Epicenter near Dali: 1. 🔴 M7.6级 [中国地震台网] 📍 汤加群岛 📏 Distance: 5000km ⏰ 2026-03-24 12:37:50 📊 Depth: 250km Please stay safe! ``` --- ## Security Webhook URLs are encrypted using AES-256-CBC before storing in config file: ```javascript // Set webhook (automatically encrypted) await config({ webhook: 'https://oapi.dingtalk.com/robot/send?access_token=xxx' }) // Stored encrypted, decrypted only in memory ``` --- ## Performance ### Shared Cache - Reduces redundant API calls - TTL-based expiration (1 minute default) - Auto-cleanup every 5 minutes ### Parallel Fetching - All three data sources fetched simultaneously - Fast response time --- ## Return Format ```javascript { timestamp: "2026-03-24T14:30:00.000Z", sources: [ { source: "CENC", sourceName: "中国地震台网", count: 10, earthquakes: [...] }, { source: "JMA", sourceName: "日本气象厅", count: 5, earthquakes: [...] } ], earthquakes: [...], // Merged, sorted by time totalCount: 15, nearbyEarthquakes: [...], // Within distanceThreshold hasAlert: true/false, alertMessage: "..." // Formatted alert string } ``` --- ## Notes - 🌐 Data from official government agencies (CENC/CWA/JMA) - 🔑 No API key required - 📡 WebSocket + HTTP fallback - 🔄 Auto-retry on failure --- ## Support - Author: fungjcode - GitHub: https://github.com/fungjcode/earthquake-monitor - Report issues at: https://github.com/fungjcode/earthquake-monitor/issues

path: .gitignore
content: # Dependencies
node_modules/

# Logs
logs/
*.log

# Config (contains sensitive data)
config.json

# OS files
.DS_Store
Thumbs.db

# IDE
.vscode/
.idea/

# Build
dist/


path: config.json
content: {
  "initialized": true,
  "location": {
    "name": "大理",
    "latitude": 25.6069,
    "longitude": 100.2679
  },
  "notify": {
    "distanceThreshold": 300,
    "minMagnitude": 3,
    "enabled": true
  },
  "language": "en",
  "sources": {
    "CENC": true,
    "JMA": true,
    "CWA": true
  },
  "webhook": null,
  "lastEarthquakeIds": {
    "CENC": "",
    "CWA": "",
    "JMA": ""
  }
}

path: index.js
content: /**
 * Earthquake Monitor - Main Entry (v1.1.0)
 * Real-time earthquake monitoring for China, Taiwan, and Japan
 * 
 * v1.1.0 Updates:
 * - Multi-language support (zh/en/ja)
 * - Enhanced location fuzzy matching (pinyin, abbreviations)
 * - Secure webhook storage
 * - Shared cache for performance
 */

const { getCENCData } = require('./src/cenc');
const { getCWAData } = require('./src/cwa');
const { getJMAData } = require('./src/jma');
const { calculateDistance } = require('./src/distance');
const { getConfig, setConfig, initConfig, parseLocation, CITY_COORDINATES } = require('./src/config');
const { startMonitor, stopMonitor, getStatus } = require('./src/monitor');
const { formatAlertMessage, detectLang } = require('./src/i18n');

/**
 * Normalize earthquake data from different sources
 */
function normalize(data, source) {
  if (!data || (Array.isArray(data) && data.length === 0)) return [];
  
  const items = Array.isArray(data) ? data : [data];
  
  return items.map(eq => {
    const normalized = {
      source,
      time: '',
      location: '',
      magnitude: 0,
      latitude: 0,
      longitude: 0,
      depth: 0,
      intensity: ''
    };
    
    if (source === 'CENC') {
      normalized.time = eq.time;
      normalized.location = eq.location || eq.placeName;
      normalized.magnitude = parseFloat(eq.magnitude);
      normalized.latitude = parseFloat(eq.latitude);
      normalized.longitude = parseFloat(eq.longitude);
      normalized.depth = parseFloat(eq.depth);
      normalized.intensity = eq.intensity;
      normalized.id = eq.EventID;
    } else if (source === 'CWA') {
      normalized.time = eq.OriginTime;
      normalized.location = eq.HypoCenter;
      normalized.magnitude = parseFloat(eq.Magunitude);
      normalized.latitude = parseFloat(eq.Latitude);
      normalized.longitude = parseFloat(eq.Longitude);
      normalized.depth = parseFloat(eq.Depth);
      normalized.intensity = eq.MaxIntensity;
      normalized.id = eq.ID;
      normalized.isWarning = true;
    } else if (source === 'JMA') {
      normalized.time = eq.time;
      normalized.location = eq.location;
      normalized.magnitude = parseFloat(eq.magnitude);
      normalized.latitude = parseFloat(eq.latitude);
      normalized.longitude = parseFloat(eq.longitude);
      normalized.depth = eq.depth;
      normalized.intensity = eq.shindo;
      normalized.id = eq.EventID || eq.no;
    }
    
    return normalized;
  });
}

/**
 * Initialize skill configuration
 * @param {Object} options - Configuration options
 * @param {string|Object} options.location - City name or coordinates
 * @param {number} [options.distanceThreshold=300] - Alert distance (km)
 * @param {number} [options.minMagnitude=3.0] - Minimum magnitude
 * @param {string} [options.language='zh'] - Language (zh/en/ja)
 * @param {Object} [options.sources] - Data source toggle {CENC: true, JMA: true, CWA: true}
 */
async function init(options = {}) {
  const config = await initConfig(options);
  
  let locationInfo = '';
  if (config.location && config.location.name) {
    locationInfo = `\n📍 Location: ${config.location.name} (${config.location.latitude}, ${config.location.longitude})`;
  }
  
  const langNames = { zh: '中文', en: 'English', ja: '日本語' };
  
  return {
    success: true,
    message: `✅ Earthquake Monitor initialized!\n🔔 Alert: Within ${config.notify.distanceThreshold}km, Mag ${config.notify.minMagnitude}+\n🌐 Language: ${langNames[config.language] || config.language}${locationInfo}\n\nUsage:\n- "earthquake" / "getAll()" - Get latest earthquakes\n- "getCENC()" - China earthquake data\n- "getJMA()" - Japan earthquake data\n- "getCWA()" - Taiwan earthquake data\n- "start()" - Start proactive monitoring\n- "config()" - View/modify configuration\n- "cities()" - List supported cities`,
    config
  };
}

/**
 * Get CENC (China) earthquake data
 */
async function getCENC(limit = 10) {
  const data = await getCENCData();
  return {
    source: 'CENC',
    sourceName: '中国地震台网',
    count: data.length,
    earthquakes: normalize(data, 'CENC').slice(0, limit)
  };
}

/**
 * Get CWA (Taiwan) earthquake early warning data
 */
async function getCWA() {
  const data = await getCWAData();
  if (!data) {
    return { source: 'CWA', sourceName: '台湾中央气象署', count: 0, earthquakes: [] };
  }
  return {
    source: 'CWA',
    sourceName: '台湾中央气象署',
    count: 1,
    earthquakes: normalize([data], 'CWA'),
    isWarning: true
  };
}

/**
 * Get JMA (Japan) earthquake data
 */
async function getJMA(limit = 10) {
  const data = await getJMAData();
  return {
    source: 'JMA',
    sourceName: '日本气象厅',
    count: data.length,
    earthquakes: normalize(data, 'JMA').slice(0, limit)
  };
}

/**
 * Get all earthquake data from all sources
 * @param {Object} options - Query options
 * @param {number} [options.limit=5] - Results per source
 * @param {boolean} [options.checkMyLocation=true] - Check if affecting monitored location
 */
async function getAll(options = {}) {
  const { limit = 5, checkMyLocation = true } = options;
  
  const [cenc, cwa, jma] = await Promise.allSettled([
    getCENC(limit),
    getCWA(),
    getJMA(limit)
  ]);
  
  const results = { timestamp: new Date().toISOString(), sources: [] };
  
  if (cenc.status === 'fulfilled') results.sources.push(cenc.value);
  if (cwa.status === 'fulfilled' && cwa.value.count > 0) results.sources.push(cwa.value);
  if (jma.status === 'fulfilled') results.sources.push(jma.value);
  
  // Merge all earthquakes
  const allEarthquakes = results.sources
    .flatMap(s => s.earthquakes.map(eq => ({...eq, sourceName: s.sourceName})))
    .sort((a, b) => new Date(b.time) - new Date(a.time))
    .slice(0, 15);
  
  results.earthquakes = allEarthquakes;
  results.totalCount = allEarthquakes.length;
  
  // Check if affecting monitored location
  if (checkMyLocation) {
    const config = await getConfig();
    if (config.location && config.notify.enabled) {
      const lang = config.language || 'zh';
      
      const nearby = allEarthquakes.filter(eq => {
        const dist = calculateDistance(
          config.location.latitude, config.location.longitude,
          eq.latitude, eq.longitude
        );
        eq.distance = dist;
        return dist <= config.notify.distanceThreshold && eq.magnitude >= config.notify.minMagnitude;
      });
      
      results.nearbyEarthquakes = nearby;
      results.hasAlert = nearby.length > 0;
      if (nearby.length > 0) {
        results.alertMessage = formatAlertMessage(nearby, config.location.name, lang);
      }
    }
  }
  
  return results;
}

/**
 * List supported cities
 */
async function cities() {
  const cityList = Object.entries(CITY_COORDINATES).map(([name, data]) => ({
    name,
    pinyin: data.pinyin.join(', '),
    abbr: data.abbr,
    latitude: data.latitude,
    longitude: data.longitude
  }));
  
  return {
    count: cityList.length,
    cities: cityList
  };
}

/**
 * Start proactive monitoring
 * @param {Object} options - Monitoring options
 * @param {number} [options.interval=60000] - Check interval in ms
 */
async function start(options = {}) {
  return await startMonitor(options);
}

/**
 * Stop monitoring
 */
async function stop() {
  return stopMonitor();
}

/**
 * Get/set configuration
 * @param {Object} [newConfig] - New configuration
 */
async function config(newConfig = null) {
  if (newConfig) {
    // Support location string for convenience
    if (typeof newConfig.location === 'string') {
      const parsed = parseLocation(newConfig.location);
      if (parsed) {
        newConfig.location = parsed;
      }
    }
    const config = await setConfig(newConfig);
    return { success: true, message: '✅ Configuration updated', config };
  }
  return await getConfig();
}

/**
 * Get monitoring status
 */
async function status() {
  return getStatus();
}

/**
 * Legacy alias for backward compatibility
 */
const getAllEarthquakes = getAll;

module.exports = {
  init,
  getCENC,
  getCWA,
  getJMA,
  getAll,
  getAllEarthquakes, // Legacy alias
  cities,
  start,
  stop,
  config,
  status,
  parseLocation, // Export for advanced usage
  CITY_COORDINATES // Export city list
};


path: package.json
content: {
  "name": "earthquake-monitor",
  "version": "1.1.1",
  "description": "Real-time earthquake monitoring for China, Taiwan, and Japan. v1.1.1 - Multi-language support, enhanced location matching, security & performance optimization",
  "author": {
    "name": "fungjcode",
    "github": "https://github.com/fungjcode"
  },
  "keywords": [
    "earthquake",
    "seismic",
    "CENC",
    "JMA",
    "CWA",
    "monitor",
    "alert",
    "i18n"
  ],
  "homepage": "https://github.com/fungjcode/earthquake-monitor",
  "repository": {
    "type": "git",
    "url": "https://github.com/fungjcode/earthquake-monitor.git"
  },
  "readme": "SKILL.md",
  "dependencies": {
    "ws": "^8.19.0"
  }
}


path: SECURITY.md
content: # Security Policy

## Encryption Note

Version 1.1.0 and earlier used AES-256-CBC encryption for webhook URL storage in the configuration file.

**Important:** As of v1.1.1, encryption has been removed for ClawHub compatibility. Webhook URLs are now stored in plain text in the configuration file.

If you need to protect sensitive webhook URLs, consider:
- Using file permissions to restrict access to `config.json`
- Implementing encryption at the application level outside this skill
- Using environment variables for sensitive configuration

## Reporting Security Issues

If you discover a security vulnerability in this skill, please report it by creating an issue on GitHub.

## Data Sources

This skill fetches earthquake data from:
- China Earthquake Networks Center (CENC)
- Taiwan Central Weather Administration (CWA)  
- Japan Meteorological Agency (JMA)

No personal data is collected or transmitted to third parties beyond these official government data sources.


path: skill.json
content: {
  "name": "earthquake-monitor",
  "version": "1.1.1",
  "description": "Real-time earthquake monitoring v1.1.1 - CENC/CWA/JMA data, multi-language alerts (zh/en/ja), pinyin location matching, secure webhook storage, optimized cache",
  "author": {
    "name": "fungjcode",
    "github": "https://github.com/fungjcode"
  },
  "keywords": ["earthquake", "seismic", "CENC", "JMA", "CWA", "monitor", "alert", "中国地震", "日本地震", "台湾地震", "预警", "i18n"],
  "homepage": "https://github.com/fungjcode/earthquake-monitor",
  "repository": {
    "type": "git",
    "url": "https://github.com/fungjcode/earthquake-monitor.git"
  },
  "readme": "SKILL.md",
  "requires": {
    "node": ">=18.0.0"
  }
}


path: src/cache.js
content: /**
 * Shared Cache Module (v1.1.0)
 * Performance optimization - shared cache between data sources
 */

class SharedCache {
  constructor() {
    this.cache = new Map();
    this.defaultTTL = 60000; // 1 minute default
  }

  /**
   * Get value from cache
   * @param {string} key - Cache key
   * @param {boolean} autoRefresh - Auto refresh if expired
   */
  get(key, autoRefresh = false) {
    const entry = this.cache.get(key);
    if (!entry) return null;
    
    const now = Date.now();
    if (now > entry.expiresAt) {
      this.cache.delete(key);
      if (autoRefresh && typeof entry.refresh === 'function') {
        // Return stale data but trigger refresh
        setImmediate(() => entry.refresh());
      }
      return null;
    }
    
    return entry.value;
  }

  /**
   * Set value in cache
   * @param {string} key - Cache key
   * @param {*} value - Value to cache
   * @param {number} ttl - Time to live in ms
   * @param {Function} refresh - Optional refresh function
   */
  set(key, value, ttl = this.defaultTTL, refresh = null) {
    this.cache.set(key, {
      value,
      expiresAt: Date.now() + ttl,
      refresh
    });
  }

  /**
   * Delete specific key
   */
  delete(key) {
    this.cache.delete(key);
  }

  /**
   * Clear all cache
   */
  clear() {
    this.cache.clear();
  }

  /**
   * Get cache stats
   */
  stats() {
    let valid = 0;
    let expired = 0;
    const now = Date.now();
    
    for (const entry of this.cache.values()) {
      if (now > entry.expiresAt) {
        expired++;
      } else {
        valid++;
      }
    }
    
    return {
      total: this.cache.size,
      valid,
      expired,
      hitRate: valid / Math.max(this.cache.size, 1)
    };
  }

  /**
   * Cleanup expired entries
   */
  cleanup() {
    const now = Date.now();
    let cleaned = 0;
    
    for (const [key, entry] of this.cache.entries()) {
      if (now > entry.expiresAt) {
        this.cache.delete(key);
        cleaned++;
      }
    }
    
    return cleaned;
  }
}

// Singleton instance
const sharedCache = new SharedCache();

// Auto cleanup every 5 minutes
setInterval(() => {
  const cleaned = sharedCache.cleanup();
  if (cleaned > 0) {
    console.log(`[Cache] Cleaned ${cleaned} expired entries`);
  }
}, 300000);

module.exports = { SharedCache, sharedCache };


path: src/cenc.js
content: /**
 * CENC - 中国地震台网
 */

const { exec } = require('child_process');
const util = require('util');
const execPromise = util.promisify(exec);

let cachedData = [];
let lastUpdate = null;

async function getCENCData() {
  // 1分钟缓存
  if (cachedData.length > 0 && lastUpdate && (Date.now() - lastUpdate) < 60000) {
    return cachedData;
  }
  
  try {
    const { stdout } = await execPromise('curl -s --max-time 10 "https://api.wolfx.jp/cenc_eqlist.json"');
    const json = JSON.parse(stdout);
    
    const data = [];
    for (let i = 1; i <= 50; i++) {
      const key = 'No' + i;
      if (json[key]) data.push(json[key]);
    }
    
    if (data.length > 0) {
      cachedData = data;
      lastUpdate = Date.now();
      return cachedData;
    }
  } catch (e) {
    console.error('[CENC] Error:', e.message);
  }
  
  return cachedData;
}

function getLatestId(data) {
  if (!data || data.length === 0) return '';
  return data[0].EventID || data[0].time || '';
}

module.exports = { getCENCData, getLatestId, getCachedData: getCENCData };


path: src/config.js
content: /**
 * Configuration Management Module (v1.1.1)
 * - Enhanced location fuzzy matching (supports pinyin, abbreviations)
 * - Note: Encryption removed for ClawHub compatibility
 */

const fs = require('fs');
const path = require('path');

const CONFIG_PATH = path.join(__dirname, '..', 'config.json');

// Default configuration
const DEFAULT_CONFIG = {
  initialized: false,
  location: {
    name: '大理',
    latitude: 25.6069,
    longitude: 100.2679
  },
  notify: {
    distanceThreshold: 300,
    minMagnitude: 3.0,
    enabled: true
  },
  language: 'zh', // Default language (zh/en/ja)
  sources: {
    CENC: true,
    JMA: true,
    CWA: true
  },
  webhook: null,
  lastEarthquakeIds: {
    CENC: '',
    CWA: '',
    JMA: ''
  }
};

// City coordinates with pinyin and abbreviations
const CITY_COORDINATES = {
  '大理': { name: '大理', pinyin: ['dali', 'dl'], abbr: 'DL', latitude: 25.6069, longitude: 100.2679 },
  '昆明': { name: '昆明', pinyin: ['kunming', 'km'], abbr: 'KM', latitude: 25.04, longitude: 102.71 },
  '丽江': { name: '丽江', pinyin: ['lijiang', 'lj'], abbr: 'LJ', latitude: 26.87, longitude: 100.23 },
  '北京': { name: '北京', pinyin: ['beijing', 'bj'], abbr: 'BJ', latitude: 39.90, longitude: 116.40 },
  '上海': { name: '上海', pinyin: ['shanghai', 'sh'], abbr: 'SH', latitude: 31.23, longitude: 121.47 },
  '广州': { name: '广州', pinyin: ['guangzhou', 'gz'], abbr: 'GZ', latitude: 23.12, longitude: 113.26 },
  '深圳': { name: '深圳', pinyin: ['shenzhen', 'sz'], abbr: 'SZ', latitude: 22.54, longitude: 114.06 },
  '成都': { name: '成都', pinyin: ['chengdu', 'cd'], abbr: 'CD', latitude: 30.57, longitude: 104.07 },
  '重庆': { name: '重庆', pinyin: ['chongqing', 'cq'], abbr: 'CQ', latitude: 29.55, longitude: 106.55 },
  '杭州': { name: '杭州', pinyin: ['hangzhou', 'hz'], abbr: 'HZ', latitude: 30.27, longitude: 120.15 },
  '西安': { name: '西安', pinyin: ['xian', 'xa'], abbr: 'XA', latitude: 34.27, longitude: 108.95 },
  '南京': { name: '南京', pinyin: ['nanjing', 'nj'], abbr: 'NJ', latitude: 32.06, longitude: 118.78 },
  '武汉': { name: '武汉', pinyin: ['wuhan', 'wh'], abbr: 'WH', latitude: 30.58, longitude: 114.29 },
  '台北': { name: '台北', pinyin: ['taipei', 'tb'], abbr: 'TB', latitude: 25.03, longitude: 121.56 },
  '东京': { name: '东京', pinyin: ['tokyo', 'dj'], abbr: 'DJ', latitude: 35.68, longitude: 139.69 },
  '大阪': { name: '大阪', pinyin: ['osaka', 'db'], abbr: 'DB', latitude: 34.69, longitude: 135.50 },
  '首尔': { name: '首尔', pinyin: ['seoul', 'se'], abbr: 'SE', latitude: 37.57, longitude: 126.98 },
  '香港': { name: '香港', pinyin: ['hongkong', 'hk'], abbr: 'HK', latitude: 22.32, longitude: 114.17 },
  '三亚': { name: '三亚', pinyin: ['sanya', 'sy'], abbr: 'SY', latitude: 18.25, longitude: 109.51 },
  '青岛': { name: '青岛', pinyin: ['qingdao', 'qd'], abbr: 'QD', latitude: 36.07, longitude: 120.37 }
};

function loadConfig() {
  try {
    if (fs.existsSync(CONFIG_PATH)) {
      return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
    }
  } catch (e) {
    console.error('[Config] Load error:', e.message);
  }
  return { ...DEFAULT_CONFIG };
}

function saveConfig(config) {
  try {
    const dir = path.dirname(CONFIG_PATH);
    if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
    fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
    return true;
  } catch (e) {
    console.error('[Config] Save error:', e.message);
    return false;
  }
}

/**
 * Enhanced location parsing with pinyin and abbreviation support
 */
function parseLocation(cityName) {
  if (!cityName) return null;
  const normalized = cityName.trim().toLowerCase();
  
  // Direct match
  if (CITY_COORDINATES[normalized]) {
    const city = CITY_COORDINATES[normalized];
    return { name: city.name, latitude: city.latitude, longitude: city.longitude };
  }
  
  // Search by pinyin or abbreviation
  for (const [name, city] of Object.entries(CITY_COORDINATES)) {
    // Match pinyin
    if (city.pinyin.some(py => py.toLowerCase() === normalized)) {
      return { name: city.name, latitude: city.latitude, longitude: city.longitude };
    }
    // Match abbreviation
    if (city.abbr.toLowerCase() === normalized) {
      return { name: city.name, latitude: city.latitude, longitude: city.longitude };
    }
    // Partial match (contains)
    if (city.pinyin.some(py => py.toLowerCase().includes(normalized)) || 
        normalized.includes(city.name.toLowerCase())) {
      return { name: city.name, latitude: city.latitude, longitude: city.longitude };
    }
    // Chinese character partial match
    if (name.includes(normalized) || normalized.includes(name)) {
      return { name: city.name, latitude: city.latitude, longitude: city.longitude };
    }
  }
  
  // Try to parse as coordinates directly
  const coords = normalized.match(/^([-\d.]+)[,\s]+([-\d.]+)$/);
  if (coords) {
    const lat = parseFloat(coords[1]);
    const lon = parseFloat(coords[2]);
    if (isValidCoordinates(lat, lon)) {
      return { name: `${lat.toFixed(2)},${lon.toFixed(2)}`, latitude: lat, longitude: lon };
    }
  }
  
  return null;
}

function isValidCoordinates(lat, lon) {
  return lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180 && !isNaN(lat) && !isNaN(lon);
}

async function getConfig() {
  return loadConfig();
}

async function setConfig(newConfig) {
  const config = loadConfig();
  
  if (newConfig.location) {
    if (typeof newConfig.location === 'string') {
      const parsed = parseLocation(newConfig.location);
      if (parsed) config.location = parsed;
    } else if (newConfig.location.latitude && newConfig.location.longitude) {
      if (isValidCoordinates(newConfig.location.latitude, newConfig.location.longitude)) {
        config.location = {
          name: newConfig.location.name || config.location.name,
          latitude: newConfig.location.latitude,
          longitude: newConfig.location.longitude
        };
      }
    }
  }
  
  if (newConfig.notify) {
    config.notify = { ...config.notify, ...newConfig.notify };
  }
  
  if (newConfig.language && ['zh', 'en', 'ja'].includes(newConfig.language)) {
    config.language = newConfig.language;
  }
  
  if (newConfig.sources) {
    config.sources = { ...config.sources, ...newConfig.sources };
  }
  
  if (newConfig.webhook !== undefined) {
    config.webhook = newConfig.webhook;
  }
  
  config.initialized = true;
  saveConfig(config);
  return config;
}

async function initConfig(options = {}) {
  const config = loadConfig();
  
  // Set location
  if (options.location) {
    if (typeof options.location === 'string') {
      const parsed = parseLocation(options.location);
      if (parsed) config.location = parsed;
    } else if (options.location.latitude && options.location.longitude) {
      if (isValidCoordinates(options.location.latitude, options.location.longitude)) {
        config.location = {
          name: options.location.name || '',
          latitude: options.location.latitude,
          longitude: options.location.longitude
        };
      }
    }
  } else {
    config.location = { name: '大理', latitude: 25.6069, longitude: 100.2679 };
  }
  
  if (options.distanceThreshold) config.notify.distanceThreshold = options.distanceThreshold;
  if (options.minMagnitude) config.notify.minMagnitude = options.minMagnitude;
  if (options.language && ['zh', 'en', 'ja'].includes(options.language)) {
    config.language = options.language;
  }
  if (options.sources) {
    config.sources = { ...config.sources, ...options.sources };
  }
  
  config.notify.enabled = true;
  config.initialized = true;
  
  saveConfig(config);
  return config;
}

async function updateLastIds(ids) {
  const config = loadConfig();
  config.lastEarthquakeIds = { ...config.lastEarthquakeIds, ...ids };
  saveConfig(config);
}

module.exports = {
  getConfig,
  setConfig,
  initConfig,
  updateLastIds,
  parseLocation,
  isValidCoordinates,
  CITY_COORDINATES,
  DEFAULT_CONFIG
};


path: src/cwa.js
content: /**
 * CWA - 台湾中央气象署地震预警
 */

const { exec } = require('child_process');
const util = require('util');
const execPromise = util.promisify(exec);

let cachedWarning = null;
let lastUpdate = null;

async function getCWAData() {
  // 30秒缓存
  if (cachedWarning && lastUpdate && (Date.now() - lastUpdate) < 30000) {
    return cachedWarning;
  }
  
  try {
    const { stdout } = await execPromise('curl -s --max-time 10 "https://api.wolfx.jp/cwa_eew.json"');
    // 检查是否返回 HTML（被 Cloudflare 拦截）
    if (!stdout || stdout.trim().startsWith('<')) {
      console.log('[CWA] API temporarily unavailable');
      return cachedWarning;
    }
    const data = JSON.parse(stdout);
    
    if (data && data.ID) {
      cachedWarning = data;
      lastUpdate = Date.now();
      return cachedWarning;
    }
  } catch (e) {
    console.log('[CWA] Error:', e.message);
  }
  
  return cachedWarning;
}

function getLatestId(data) {
  if (!data || !data.ID) return '';
  return String(data.ID);
}

module.exports = { getCWAData, getLatestId, getCachedData: getCWAData };


path: src/distance.js
content: /**
 * 距离计算 - Haversine 公式
 */

function calculateDistance(lat1, lon1, lat2, lon2) {
  const R = 6371;
  const dLat = toRad(lat2 - lat1);
  const dLon = toRad(lon2 - lon1);
  const a = Math.sin(dLat/2) * Math.sin(dLat/2) +
            Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) *
            Math.sin(dLon/2) * Math.sin(dLon/2);
  const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
  return R * c;
}

function toRad(deg) {
  return deg * Math.PI / 180;
}

module.exports = { calculateDistance };


path: src/i18n.js
content: /**
 * Internationalization (i18n) Module
 * Multi-language support for earthquake alerts
 */

const translations = {
  zh: {
    alertTitle: '⚠️ 地震预警提醒！',
    alertSubtitle: '📍 震中距离 {location} 较近：\n',
    magnitude: '震级',
    location: '震源',
    distance: '距离约',
    time: '时间',
    depth: '震源深度',
    source: '来源',
    safetyNotice: '请注意安全！',
    noEarthquake: '暂无地震信息',
    monitoring: '监控中',
    lastCheck: '最后检查',
    unit: {
      km: 'km',
      magnitude: '级'
    }
  },
  en: {
    alertTitle: '⚠️ Earthquake Alert!',
    alertSubtitle: '📍 Epicenter near {location}:\n',
    magnitude: 'Magnitude',
    location: 'Location',
    distance: 'Distance',
    time: 'Time',
    depth: 'Depth',
    source: 'Source',
    safetyNotice: 'Please stay safe!',
    noEarthquake: 'No earthquake information',
    monitoring: 'Monitoring',
    lastCheck: 'Last check',
    unit: {
      km: 'km',
      magnitude: ''
    }
  },
  ja: {
    alertTitle: '⚠️ 地震アラート！',
    alertSubtitle: '📍 震源地が {location} の近く:\n',
    magnitude: 'マグニチュード',
    location: '場所',
    distance: '距離',
    time: '時刻',
    depth: '震源深度',
    source: '情報源',
    safetyNotice: '安全にお気をつけて！',
    noEarthquake: '地震情報なし',
    monitoring: '監視中',
    lastCheck: '最終確認',
    unit: {
      km: 'km',
      magnitude: ''
    }
  }
};

// Language detection based on source
const sourceLangMap = {
  'CENC': 'zh',
  '中国地震台网': 'zh',
  '台湾中央气象署': 'zh',
  'CWA': 'zh',
  'JMA': 'ja',
  '日本气象厅': 'ja'
};

/**
 * Get translation for a key
 * @param {string} lang - Language code (zh/en/ja)
 * @param {string} key - Translation key
 * @param {Object} params - Parameters for placeholder replacement
 */
function t(lang, key, params = {}) {
  const langData = translations[lang] || translations.zh;
  let text = langData[key] || translations.zh[key] || key;
  
  // Replace placeholders like {location}
  for (const [param, value] of Object.entries(params)) {
    text = text.replace(new RegExp(`\\{${param}\\}`, 'g'), value);
  }
  
  return text;
}

/**
 * Detect language based on earthquake source
 * @param {string} source - Data source name
 */
function detectLang(source) {
  return sourceLangMap[source] || 'zh';
}

/**
 * Format alert message in specific language
 * @param {Array} earthquakes - Array of earthquake objects
 * @param {string} locationName - Location name
 * @param {string} lang - Language code
 */
function formatAlertMessage(earthquakes, locationName, lang = 'zh') {
  const lines = [t(lang, 'alertTitle')];
  lines.push(t(lang, 'alertSubtitle', { location: locationName }), '');
  
  earthquakes.forEach((eq, i) => {
    const emoji = eq.magnitude >= 5 ? '🔴' : eq.magnitude >= 4 ? '🟠' : '🟡';
    const eqLang = detectLang(eq.sourceName || eq.source);
    const units = translations[eqLang]?.unit || translations.zh.unit;
    
    lines.push(`${i + 1}. ${emoji} M${eq.magnitude}${units.magnitude} [${eq.sourceName || eq.source}]`);
    lines.push(`   📍 ${eq.location}`);
    lines.push(`   📏 ${t(eqLang, 'distance')}: ${Math.round(eq.distance)}${units.km}`);
    lines.push(`   ⏰ ${eq.time}`);
    if (eq.depth) lines.push(`   📊 ${t(eqLang, 'depth')}: ${eq.depth}${units.km}`);
    lines.push('');
  });
  
  lines.push(t(lang, 'safetyNotice'));
  return lines.join('\n');
}

module.exports = { t, detectLang, formatAlertMessage, translations };


path: src/jma.js
content: /**
 * JMA - 日本气象厅
 */

const { exec } = require('child_process');
const util = require('util');
const execPromise = util.promisify(exec);

let cachedData = [];
let lastUpdate = null;

async function getJMAData() {
  // 1分钟缓存
  if (cachedData.length > 0 && lastUpdate && (Date.now() - lastUpdate) < 60000) {
    return cachedData;
  }
  
  try {
    const { stdout } = await execPromise('curl -s --max-time 10 "https://api.wolfx.jp/jma_eqlist.json"');
    const json = JSON.parse(stdout);
    
    const data = [];
    for (let i = 1; i <= 50; i++) {
      const key = 'No' + i;
      if (json[key]) data.push(json[key]);
    }
    
    if (data.length > 0) {
      cachedData = data;
      lastUpdate = Date.now();
      return cachedData;
    }
  } catch (e) {
    console.error('[JMA] Error:', e.message);
  }
  
  return cachedData;
}

function getLatestId(data) {
  if (!data || data.length === 0) return '';
  return data[0].EventID || data[0].time || '';
}

module.exports = { getJMAData, getLatestId, getCachedData: getJMAData };


path: src/monitor.js
content: /**
 * Earthquake Monitoring & Alert Module (v1.1.0)
 * - Fixed CWA data source integration
 * - Multi-language alert support
 * - Enhanced deduplication
 * - Webhook payload format support
 */

const { getCENCData, getCachedData: getCENCCached } = require('./cenc');
const { getCWAData, getCachedData: getCWACached } = require('./cwa');
const { getJMAData, getCachedData: getJMACached } = require('./jma');
const { calculateDistance } = require('./distance');
const { getConfig, setConfig } = require('./config');
const { formatAlertMessage, detectLang } = require('./i18n');

let monitorInterval = null;
let lastNotificationTime = 0;
const NOTIFICATION_COOLDOWN = 300000; // 5 minutes

// Track notified earthquake IDs (EventID level deduplication)
const notifiedEarthquakes = new Set();

/**
 * Generate unique earthquake ID
 */
function generateEarthquakeId(eq, source) {
  const time = eq.time || '';
  const mag = eq.magnitude || '0';
  const loc = eq.location || '';
  const depth = eq.depth || '0';
  return `${source}-${time}-${mag}-${loc}-${depth}`;
}

/**
 * Start earthquake monitoring
 * @param {Object} options - Configuration options
 * @param {Function} onAlert - Alert callback function
 * @param {number} interval - Check interval in ms (default 60000)
 */
async function startMonitor(options = {}, onAlert = null, interval = 60000) {
  const config = await getConfig();
  
  if (!config.notify.enabled) {
    return { success: false, message: 'Alerts disabled. Enable in config.' };
  }
  
  if (!config.location || !config.location.latitude) {
    return { success: false, message: 'Please set monitoring location first using init().' };
  }
  
  // Stop existing monitor if running
  if (monitorInterval) {
    stopMonitor();
  }
  
  console.log(`[Earthquake Monitor] Started: ${config.location.name}`);
  console.log(`[Earthquake Monitor] Alert: Within ${config.notify.distanceThreshold}km, Mag ${config.notify.minMagnitude}+`);
  console.log(`[Earthquake Monitor] Language: ${config.language}`);
  
  // Initial check
  await checkAndNotify(onAlert);
  
  // Set interval
  monitorInterval = setInterval(async () => {
    await checkAndNotify(onAlert);
  }, interval);
  
  return { 
    success: true, 
    message: `✅ Earthquake monitoring started!\n📍 Location: ${config.location.name}\n🔔 Alert: Within ${config.notify.distanceThreshold}km, Mag ${config.notify.minMagnitude}+\n⏰ Check interval: ${interval/1000}s\n🌐 Language: ${config.language}` 
  };
}

/**
 * Stop monitoring
 */
function stopMonitor() {
  if (monitorInterval) {
    clearInterval(monitorInterval);
    monitorInterval = null;
    console.log('[Earthquake Monitor] Stopped');
  }
  return { success: true, message: '✅ Monitoring stopped' };
}

/**
 * Check earthquakes and send notifications
 */
async function checkAndNotify(onAlert) {
  const config = await getConfig();
  if (!config.notify.enabled) return;
  
  try {
    // Fetch all data sources in parallel
    const [cencData, cwaData, jmaData] = await Promise.allSettled([
      config.sources.CENC !== false ? getCENCCached() : Promise.resolve([]),
      config.sources.CWA !== false ? getCWACached() : Promise.resolve(null),
      config.sources.JMA !== false ? getJMACached() : Promise.resolve([])
    ]);
    
    const earthquakes = [];
    const now = Date.now();
    
    // Process CENC data (China)
    if (cencData.status === 'fulfilled' && cencData.value.length > 0) {
      for (const eq of cencData.value) {
        const id = generateEarthquakeId({
          time: eq.time,
          magnitude: eq.magnitude,
          location: eq.location,
          depth: eq.depth
        }, 'CENC');
        
        if (!notifiedEarthquakes.has(id) && eq.time !== config.lastEarthquakeIds?.CENC) {
          earthquakes.push({
            source: 'CENC',
            sourceName: '中国地震台网',
            id,
            EventID: eq.EventID,
            time: eq.time,
            location: eq.location,
            magnitude: parseFloat(eq.magnitude),
            latitude: parseFloat(eq.latitude),
            longitude: parseFloat(eq.longitude),
            depth: parseFloat(eq.depth),
            intensity: eq.intensity
          });
        }
      }
    }
    
    // Process CWA data (Taiwan)
    if (cwaData.status === 'fulfilled' && cwaData.value && cwaData.value.ID) {
      const eq = cwaData.value;
      const id = generateEarthquakeId({
        time: eq.OriginTime,
        magnitude: eq.Magunitude,
        location: eq.HypoCenter,
        depth: eq.Depth
      }, 'CWA');
      
      if (!notifiedEarthquakes.has(id) && eq.OriginTime !== config.lastEarthquakeIds?.CWA) {
        earthquakes.push({
          source: 'CWA',
          sourceName: '台湾中央气象署',
          id,
          EventID: eq.ID,
          time: eq.OriginTime,
          location: eq.HypoCenter,
          magnitude: parseFloat(eq.Magunitude),
          latitude: parseFloat(eq.Latitude),
          longitude: parseFloat(eq.Longitude),
          depth: parseFloat(eq.Depth),
          intensity: eq.MaxIntensity
        });
      }
    }
    
    // Process JMA data (Japan)
    if (jmaData.status === 'fulfilled' && jmaData.value.length > 0) {
      for (const eq of jmaData.value) {
        const id = generateEarthquakeId({
          time: eq.time,
          magnitude: eq.magnitude,
          location: eq.location,
          depth: eq.depth
        }, 'JMA');
        
        if (!notifiedEarthquakes.has(id) && eq.time !== config.lastEarthquakeIds?.JMA) {
          earthquakes.push({
            source: 'JMA',
            sourceName: '日本气象厅',
            id,
            EventID: eq.EventID || eq.no,
            time: eq.time,
            location: eq.location,
            magnitude: parseFloat(eq.magnitude),
            latitude: parseFloat(eq.latitude),
            longitude: parseFloat(eq.longitude),
            depth: eq.depth,
            intensity: eq.shindo
          });
        }
      }
    }
    
    // No new earthquakes
    if (earthquakes.length === 0) return;
    
    // Sort by time
    earthquakes.sort((a, b) => new Date(b.time) - new Date(a.time));
    
    // Update last earthquake IDs
    const newConfig = { ...config };
    newConfig.lastEarthquakeIds = {
      CENC: earthquakes.find(e => e.source === 'CENC')?.EventID || config.lastEarthquakeIds?.CENC || '',
      CWA: earthquakes.find(e => e.source === 'CWA')?.EventID || config.lastEarthquakeIds?.CWA || '',
      JMA: earthquakes.find(e => e.source === 'JMA')?.EventID || config.lastEarthquakeIds?.JMA || ''
    };
    await setConfig(newConfig);
    
    // Filter by distance and magnitude
    const alerts = earthquakes.filter(eq => {
      const dist = calculateDistance(
        config.location.latitude,
        config.location.longitude,
        eq.latitude,
        eq.longitude
      );
      eq.distance = dist;
      return dist <= config.notify.distanceThreshold && 
             eq.magnitude >= config.notify.minMagnitude;
    });
    
    // Send notification
    if (alerts.length > 0 && now - lastNotificationTime > NOTIFICATION_COOLDOWN) {
      lastNotificationTime = now;
      
      // Add to notified set
      alerts.forEach(eq => notifiedEarthquakes.add(eq.id));
      
      // Get language for message formatting
      const lang = config.language || 'zh';
      const message = formatAlertMessage(alerts, config.location.name, lang);
      const webhookPayload = formatWebhookPayload(alerts, config.location.name);
      
      if (onAlert) {
        onAlert({ alerts, message, webhookPayload, lang });
      }
      
      console.log(`[Earthquake Monitor] Alert sent: ${alerts.length} earthquake(s)`);
    }
    
  } catch (e) {
    console.error('[Earthquake Monitor] Check failed:', e.message);
  }
}

/**
 * Format webhook payload for DingTalk/Feishu
 */
function formatWebhookPayload(earthquakes, locationName) {
  return {
    msgtype: 'news',
    news: {
      articles: [{
        title: `⚠️ Earthquake Alert - M${earthquakes[0]?.magnitude} near ${locationName}`,
        description: earthquakes.map((eq, i) => 
          `${i + 1}. ${eq.location} M${eq.magnitude} - ${Math.round(eq.distance)}km away`
        ).join('\n'),
        url: 'https://www.ceic.ac.cn/'
      }]
    }
  };
}

/**
 * Get monitoring status
 */
function getStatus() {
  return {
    isRunning: monitorInterval !== null,
    lastNotification: lastNotificationTime > 0 ? new Date(lastNotificationTime).toISOString() : null,
    notifiedCount: notifiedEarthquakes.size
  };
}

module.exports = {
  startMonitor,
  stopMonitor,
  checkAndNotify,
  getStatus
};


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (HIGH)