First commit
@@ -0,0 +1,4 @@
|
|||||||
|
/node_modules
|
||||||
|
/dist
|
||||||
|
/config/**
|
||||||
|
!/config/example.yaml
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
Copyright 2026-Present Last2014
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
# 地震情報Bot for Mtweet
|
||||||
|
noticeUwuzuの地震情報機能だけを抜き取ったやつです。本人なのでライセンス違反はしてません。
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# # ###### ##### ### ###### ####### # # # # # # ###### # #
|
||||||
|
## # # # # # # # # # # # # # # # # #
|
||||||
|
# ## # # # # # # ####### # # # # # # # # ## # #
|
||||||
|
# ## # # # # # # # # # # # # # # #
|
||||||
|
# # ###### # ### ###### ####### ###### # # ###### ###### ######
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
earthquake:
|
||||||
|
# 地震発生情報を投稿することに必要な最大震度 number
|
||||||
|
# 例: 30を指定すると、最大震度が震度3以上の地震発生情報のみを投稿します。
|
||||||
|
# 10(震度1), 20(震度2), 30(震度3), 40(震度4), 45(震度5弱), 50(震度5強), 55(震度6弱), 60(震度6強), 70(震度7)が有効です。
|
||||||
|
requireMaxScale: 30
|
||||||
|
# 過去のデバッグ用データを使用して地震情報を配信するかどうか boolean
|
||||||
|
# デバッグ用途のみで使用してください。
|
||||||
|
useHistoryData: false
|
||||||
|
# 再接続の間隔(ミリ秒) number
|
||||||
|
# 正数のみが有効です。
|
||||||
|
reconnectInterval: 3000
|
||||||
|
mtweet:
|
||||||
|
# APIトークン string
|
||||||
|
# 必要な権限:
|
||||||
|
# - posts:write
|
||||||
|
token: API_TOKEN
|
||||||
|
# ユーザーID string
|
||||||
|
# 後に自動化します
|
||||||
|
userid: earthquake_bot
|
||||||
|
post:
|
||||||
|
# 最大再試行数 number
|
||||||
|
# 自然数のみが有効です。
|
||||||
|
maxRetries: 3
|
||||||
|
# 再試行の間隔(ミリ秒) number
|
||||||
|
# 正数のみが有効です。
|
||||||
|
retryInterval: 1000
|
||||||
|
# 投稿の文字数制限 number
|
||||||
|
# 文字数制限を設定します。自然数のみが有効です。
|
||||||
|
# 自主規制として、利用可能な文字数制限未満の数値も有効です。
|
||||||
|
# 無料アカウント: 300文字
|
||||||
|
# VIPアカウント: 1500文字
|
||||||
|
# プレミアムアカウント: 10000文字
|
||||||
|
maxLength: 300
|
||||||
|
# デバッグモードにするかどうか boolean
|
||||||
|
debug: false
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"name": "earthquake-bot-for-mtweet",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node .",
|
||||||
|
"build": "tsc && tsc-alias && cpy \"{**/*.json,**/*.png}\" ../dist --cwd=src && cpy \"260420.json\" dist"
|
||||||
|
},
|
||||||
|
"author": {
|
||||||
|
"name": "Last2014",
|
||||||
|
"email": "info@last2014.com",
|
||||||
|
"url": "https://about.last2014.com"
|
||||||
|
},
|
||||||
|
"packageManager": "pnpm@11.1.3+sha512.c85357fe17ca12dd23dd7071822666dfd7e3cb76fe214e3370b5ea2fb34f2a231185509b63e717f3cd0acb38dd3f8d82bcd5e8172400ae678b70ea4fbed0896d",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "^25.5.2",
|
||||||
|
"@types/ws": "^8.18.1",
|
||||||
|
"cpy-cli": "^7.0.0",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
|
"fs": "0.0.1-security",
|
||||||
|
"os": "^0.1.2",
|
||||||
|
"sharp": "^0.34.5",
|
||||||
|
"tsc-alias": "^1.8.16",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
|
"util": "^0.12.5",
|
||||||
|
"ws": "^8.20.0",
|
||||||
|
"yaml": "^2.8.3",
|
||||||
|
"zod": "^4.3.6"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
allowBuilds:
|
||||||
|
canvas: true
|
||||||
|
sharp: true
|
||||||
|
miq: true
|
||||||
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 559 B |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 714 B |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 1015 B |
@@ -0,0 +1,533 @@
|
|||||||
|
import { readFileSync, writeFileSync } from "node:fs";
|
||||||
|
import sharp from "sharp";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 経度からX方向の座標情報を計算します
|
||||||
|
*
|
||||||
|
* 指定された経度とズームレベルに基づき、以下の4つの値を返します:
|
||||||
|
* - 世界座標系でのX(0〜1の範囲、グリニッジ子午線が0)
|
||||||
|
* - ズームレベルを考慮したピクセル単位のグローバルX座標
|
||||||
|
* - タイルグリッドにおけるX方向のタイル番号(tileNumberX)
|
||||||
|
* - タイル内(256×256ピクセル)でのX方向ピクセル位置(tileInnerX)
|
||||||
|
* @param lat - 経度
|
||||||
|
* @param z - ズームレベル(0以上の整数)
|
||||||
|
* @returns {{
|
||||||
|
* worldCoordX: number, // 世界座標(0~1, グリニッジ子午線が原点(0))
|
||||||
|
* globalPixelX: number, // ズームレベルを考慮したピクセル単位のY座標
|
||||||
|
* tileNumberX: number, // 該当するタイル番号(0オリジン)
|
||||||
|
* tileInnerX: number, // タイル内のピクセルY座標(0〜255)
|
||||||
|
* }}
|
||||||
|
* @copyright murasuke on Qiita - https://qiita.com/murasuke/items/ad81b7b726a3463fa3fe#%E7%B5%8C%E5%BA%A6
|
||||||
|
*/
|
||||||
|
function calcLngToTileX(lng: number, z: number) {
|
||||||
|
// 経度(-180〜180)を世界座標系のX(0〜1)に変換
|
||||||
|
const worldCoordX = (lng + 180) / 360;
|
||||||
|
|
||||||
|
// ズームレベル0(256×256px)の座標の位置を計算
|
||||||
|
let globalPixelX = worldCoordX * 256;
|
||||||
|
|
||||||
|
// ズームレベル(2^z)を考慮した座標を計算
|
||||||
|
globalPixelX = globalPixelX * Math.pow(2, z);
|
||||||
|
|
||||||
|
// 1つの画像タイルが256pxなので、256で割って左端からのタイルの枚数(位置)を求める
|
||||||
|
const tileNumberX = Math.floor(globalPixelX / 256);
|
||||||
|
|
||||||
|
// 該当タイル内のピクセル位置を算出する(タイル幅合計を引いた余り)
|
||||||
|
const tileInnerX = Math.floor(globalPixelX - tileNumberX * 256);
|
||||||
|
|
||||||
|
// 計算した値を返す
|
||||||
|
return {
|
||||||
|
worldCoordX, // 世界座標(0~1, グリニッジ子午線が原点(0))
|
||||||
|
globalPixelX, // ズームレベルを考慮したピクセル単位のY座標
|
||||||
|
tileNumberX, // 該当するタイル番号(0オリジン)
|
||||||
|
tileInnerX, // タイル内のピクセルY座標(0〜255)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 緯度からWebメルカトル投影によるY方向の座標情報を計算します。
|
||||||
|
*
|
||||||
|
* 指定された緯度とズームレベルに基づき、以下の4つの値を返します:
|
||||||
|
* - 世界座標系でのY(0〜1の範囲、北極が0)
|
||||||
|
* - ズームレベルを考慮したピクセル単位のグローバルY座標
|
||||||
|
* - タイルグリッドにおけるY方向のタイル番号(tileNumberY)
|
||||||
|
* - タイル内(256×256ピクセル)でのY方向ピクセル位置(tileInnerY)
|
||||||
|
*
|
||||||
|
* メルカトル図法で緯度から位置を算出する式 (https://qiita.com/Seo-4d696b75/items/aa6adfbfba404fcd65aa)
|
||||||
|
* R ln(tan(π/4 + ϕ/2))
|
||||||
|
* R: 半径
|
||||||
|
* ϕ: 緯度(ラジアン)
|
||||||
|
* @param lat - 緯度(-85〜+85度の範囲が推奨)
|
||||||
|
* @param z - ズームレベル(0以上の整数)
|
||||||
|
* @returns {{
|
||||||
|
* worldCoordY: number, // 世界座標系Y(0〜1)
|
||||||
|
* globalPixelY: number, // ズームレベルを考慮したピクセル単位のY座標
|
||||||
|
* tileNumberY: number, // Y方向のタイル番号(0オリジン)
|
||||||
|
* tileInnerY: number // タイル内のピクセルY座標(0〜255)
|
||||||
|
* }}
|
||||||
|
* @copyright murasuke on Qiita - https://qiita.com/murasuke/items/ad81b7b726a3463fa3fe#%E7%B7%AF%E5%BA%A6
|
||||||
|
*/
|
||||||
|
function calcLatToTileY(lat: number, z: number) {
|
||||||
|
// 緯度からメルカトル図法の座標に変換して、世界座標系のY(0~1)を計算
|
||||||
|
let worldCoordY = latToWorldY(lat);
|
||||||
|
|
||||||
|
// ズームレベル0(256×256px)の座標の位置を計算
|
||||||
|
let globalPixelY = worldCoordY * 256;
|
||||||
|
|
||||||
|
// ズームレベル(2^z)を考慮した座標を計算
|
||||||
|
globalPixelY = globalPixelY * Math.pow(2, z);
|
||||||
|
|
||||||
|
// 1つの画像タイルが256pxなので、256で割って上端からのタイルの枚数(位置)を求める
|
||||||
|
const tileNumberY = Math.floor(globalPixelY / 256);
|
||||||
|
|
||||||
|
// 該当タイル内のピクセル位置を算出する(タイル幅合計を引いた余り)
|
||||||
|
const tileInnerY = Math.floor(globalPixelY - tileNumberY * 256);
|
||||||
|
|
||||||
|
// 計算した値を返す
|
||||||
|
return {
|
||||||
|
worldCoordY, // 世界座標(0~1, 北極側が原点(0))
|
||||||
|
globalPixelY, // ズームレベルを考慮したピクセル単位のY座標
|
||||||
|
tileNumberY, // 該当するタイル番号(0オリジン)
|
||||||
|
tileInnerY, // タイル内のピクセルY座標(0〜255)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* メルカトル図法で投影した位置を元に世界座標系でのY(0〜1、北極が0)を計算する
|
||||||
|
*
|
||||||
|
* メルカトル図法の式(半径1の球体を想定)
|
||||||
|
* ϕ = (π / 180) * 緯度
|
||||||
|
* y = ln(tan(π/4 + ϕ/2))
|
||||||
|
* ϕ:緯度(ラジアン)
|
||||||
|
* y:投影した位置
|
||||||
|
*
|
||||||
|
* * 赤道を中心(0)にして、約-85〜+85度の範囲で-π~πの値になる
|
||||||
|
* * 高緯度では値が急増し無限大になる
|
||||||
|
* * y = ln((tan(ϕ) + 1) / cos(ϕ)) という式もある(同じ値になる)
|
||||||
|
*
|
||||||
|
* 地図で位置を計算するため、北極側が原点(0)で南極側が1となるように正規化した値を返す
|
||||||
|
*
|
||||||
|
* @param lat 緯度(-85~85)
|
||||||
|
* @returns {number} 世界座標系でのY(0〜1、北極が0)
|
||||||
|
* @copyright murasuke on Qiita - https://qiita.com/murasuke/items/ad81b7b726a3463fa3fe#%E7%B7%AF%E5%BA%A6
|
||||||
|
*/
|
||||||
|
function latToWorldY(lat: number) {
|
||||||
|
// 経度をラジアンに変換
|
||||||
|
const latRad = (Math.PI / 180) * lat;
|
||||||
|
// 緯度からメルカトル図法の座標に変換する
|
||||||
|
const mercatorY = Math.log(Math.tan(Math.PI / 4 + latRad / 2));
|
||||||
|
|
||||||
|
// -1~1 の範囲になるように調整(-85度~85度の範囲)
|
||||||
|
let worldCoordY = mercatorY / Math.PI;
|
||||||
|
|
||||||
|
// 原点(0)を赤道から、北極へ変換(0~1)
|
||||||
|
worldCoordY = 1 - (worldCoordY + 1) / 2;
|
||||||
|
|
||||||
|
return worldCoordY;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEdge<T extends any[]>(arr: T, property: keyof T[number]) {
|
||||||
|
if (arr.length === 0) {
|
||||||
|
return {
|
||||||
|
most: undefined,
|
||||||
|
least: undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const counts = new Map<any, number>();
|
||||||
|
for (const item of arr) {
|
||||||
|
const val = item[property];
|
||||||
|
counts.set(val, (counts.get(val) || 0) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const uniqueValues = Array.from(counts.keys());
|
||||||
|
|
||||||
|
const minVal = uniqueValues.reduce((a, b) => (a < b ? a : b));
|
||||||
|
const maxVal = uniqueValues.reduce((a, b) => (a > b ? a : b));
|
||||||
|
|
||||||
|
return {
|
||||||
|
most: maxVal,
|
||||||
|
least: minVal,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function generateImage(message: any) {
|
||||||
|
if (
|
||||||
|
message.earthquake.hypocenter === undefined ||
|
||||||
|
message.earthquake.hypocenter?.name === "" ||
|
||||||
|
!(Array.isArray(message.points)) ||
|
||||||
|
message.points.length === 0
|
||||||
|
)
|
||||||
|
return "input_lack";
|
||||||
|
|
||||||
|
const ZOOM_LEVEL = 9;
|
||||||
|
|
||||||
|
// タイル・地点取得
|
||||||
|
const tiles: {
|
||||||
|
tileX: number;
|
||||||
|
tileY: number;
|
||||||
|
}[] = [];
|
||||||
|
|
||||||
|
const positions: ({
|
||||||
|
tileIndex: number;
|
||||||
|
innerX: number;
|
||||||
|
innerY: number;
|
||||||
|
} & ({
|
||||||
|
type: "epicenter";
|
||||||
|
} | {
|
||||||
|
type: "point";
|
||||||
|
scale: number;
|
||||||
|
}))[] = [];
|
||||||
|
|
||||||
|
if (
|
||||||
|
message.earthquake.hypocenter?.latitude !== undefined &&
|
||||||
|
message.earthquake.hypocenter?.latitude !== -200 &&
|
||||||
|
message.earthquake.hypocenter?.longitude !== undefined &&
|
||||||
|
message.earthquake.hypocenter?.longitude !== -200
|
||||||
|
) {
|
||||||
|
// 震源地
|
||||||
|
const lng = message.earthquake.hypocenter.longitude;
|
||||||
|
const lat = message.earthquake.hypocenter.latitude;
|
||||||
|
const xData = calcLngToTileX(lng, ZOOM_LEVEL);
|
||||||
|
const yData = calcLatToTileY(lat, ZOOM_LEVEL);
|
||||||
|
|
||||||
|
tiles.push({
|
||||||
|
tileX: xData.tileNumberX,
|
||||||
|
tileY: yData.tileNumberY,
|
||||||
|
});
|
||||||
|
|
||||||
|
positions.push({
|
||||||
|
tileIndex: 0,
|
||||||
|
innerX: xData.tileInnerX,
|
||||||
|
innerY: yData.tileInnerY,
|
||||||
|
type: "epicenter",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
Array.isArray(message.points) &&
|
||||||
|
message.points.length > 0
|
||||||
|
) {
|
||||||
|
// 観測点・区域
|
||||||
|
const areas = JSON.parse(readFileSync(`${import.meta.dirname}/areas.json`, "utf-8"));
|
||||||
|
const points = JSON.parse(readFileSync(`${import.meta.dirname}/points.json`, "utf-8"));
|
||||||
|
message.points.forEach((point: any) => {
|
||||||
|
let areaData: {
|
||||||
|
longitude: number;
|
||||||
|
latitude: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (point.isArea) {
|
||||||
|
areaData = areas[point.addr];
|
||||||
|
} else {
|
||||||
|
areaData = points[point.addr];
|
||||||
|
}
|
||||||
|
|
||||||
|
const lng = areaData.longitude;
|
||||||
|
const lat = areaData.latitude;
|
||||||
|
const xData = calcLngToTileX(lng, ZOOM_LEVEL);
|
||||||
|
const yData = calcLatToTileY(lat, ZOOM_LEVEL);
|
||||||
|
|
||||||
|
const tilePosition = {
|
||||||
|
tileX: xData.tileNumberX,
|
||||||
|
tileY: yData.tileNumberY,
|
||||||
|
}
|
||||||
|
|
||||||
|
let tileIndex: number;
|
||||||
|
|
||||||
|
const sameTile = tiles
|
||||||
|
.map((value, index) => ({ value, index }))
|
||||||
|
.filter(tile => JSON.stringify(tile.value) === JSON.stringify(tilePosition))[0];
|
||||||
|
|
||||||
|
if (sameTile) {
|
||||||
|
tileIndex = sameTile.index;
|
||||||
|
} else {
|
||||||
|
tiles.push(tilePosition);
|
||||||
|
tileIndex = tiles.length - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tiles.filter(tile => JSON.stringify(tile) === JSON.stringify(tilePosition)).length === 0) {
|
||||||
|
tiles.push(tilePosition);
|
||||||
|
}
|
||||||
|
|
||||||
|
positions.push({
|
||||||
|
tileIndex,
|
||||||
|
innerX: xData.tileInnerX,
|
||||||
|
innerY: yData.tileInnerY,
|
||||||
|
type: "point",
|
||||||
|
scale: point.scale,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 画像生成
|
||||||
|
const WIDTH = 1920;
|
||||||
|
const HEIGHT = 1080;
|
||||||
|
|
||||||
|
let result = sharp({
|
||||||
|
create: {
|
||||||
|
width: WIDTH,
|
||||||
|
height: HEIGHT,
|
||||||
|
channels: 4,
|
||||||
|
background: "#ffffff",
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let compounds: (sharp.OverlayOptions & ({
|
||||||
|
type: "tile" | "epicenter" | "other";
|
||||||
|
} | {
|
||||||
|
type: "scale";
|
||||||
|
scale: number;
|
||||||
|
}))[] = [];
|
||||||
|
|
||||||
|
// タイルの幅、最大最小
|
||||||
|
let tileXCount = getEdge(tiles, "tileX");
|
||||||
|
let tileYCount = getEdge(tiles, "tileY");
|
||||||
|
let xSize = tileXCount.most - tileXCount.least + 1;
|
||||||
|
let ySize = tileYCount.most - tileYCount.least + 1;
|
||||||
|
|
||||||
|
// タイルサイズ仮計算
|
||||||
|
let tileSize: number;
|
||||||
|
|
||||||
|
xSize = tileXCount.most - tileXCount.least + 1;
|
||||||
|
ySize = tileYCount.most - tileYCount.least + 1;
|
||||||
|
if (xSize > ySize) {
|
||||||
|
tileSize = Math.round(WIDTH / xSize);
|
||||||
|
} else {
|
||||||
|
tileSize = Math.round(HEIGHT / ySize);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 震源を中心とする
|
||||||
|
const epicenterPosition = positions.filter(position => position.type === "epicenter")[0];
|
||||||
|
if (!epicenterPosition)
|
||||||
|
return "input_lack";
|
||||||
|
|
||||||
|
const epicenterTile = tiles[epicenterPosition.tileIndex];
|
||||||
|
if (!epicenterTile)
|
||||||
|
return "input_lack";
|
||||||
|
|
||||||
|
const leftPx = (epicenterTile.tileX - tileXCount.least) * tileSize + epicenterPosition.innerX;
|
||||||
|
const rightPx = xSize * tileSize - leftPx;
|
||||||
|
const topPx = (epicenterTile.tileY - tileYCount.least) * tileSize + epicenterPosition.innerY;
|
||||||
|
const bottomPx = ySize * tileSize - topPx;
|
||||||
|
|
||||||
|
if (leftPx > rightPx) {
|
||||||
|
const leftTiles = epicenterTile.tileX - tileXCount.least;
|
||||||
|
tileXCount = {
|
||||||
|
...tileXCount,
|
||||||
|
most: epicenterTile.tileX + leftTiles,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const rightTiles = tileXCount.most - epicenterTile.tileX;
|
||||||
|
tileXCount = {
|
||||||
|
...tileXCount,
|
||||||
|
least: epicenterTile.tileX - rightTiles,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (topPx > bottomPx) {
|
||||||
|
const topTiles = epicenterTile.tileY - tileYCount.least;
|
||||||
|
tileYCount = {
|
||||||
|
...tileYCount,
|
||||||
|
most: epicenterTile.tileY + topTiles,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const bottomTiles = tileYCount.most - epicenterTile.tileY;
|
||||||
|
tileYCount = {
|
||||||
|
...tileYCount,
|
||||||
|
least: epicenterTile.tileY - bottomTiles,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// タイルサイズ再計算
|
||||||
|
xSize = tileXCount.most - tileXCount.least + 1;
|
||||||
|
ySize = tileYCount.most - tileYCount.least + 1;
|
||||||
|
if (xSize > ySize) {
|
||||||
|
tileSize = Math.round(WIDTH / xSize);
|
||||||
|
} else {
|
||||||
|
tileSize = Math.round(HEIGHT / ySize);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 全画面
|
||||||
|
if (WIDTH - xSize * tileSize > 5) {
|
||||||
|
const requireTilesX = Math.ceil(WIDTH / tileSize);
|
||||||
|
const halfTilesX = Math.ceil(requireTilesX / 2);
|
||||||
|
|
||||||
|
tileXCount = {
|
||||||
|
least: tileXCount.least - halfTilesX,
|
||||||
|
most: tileXCount.most + halfTilesX,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (HEIGHT - ySize * tileSize > 5) {
|
||||||
|
const requireTilesY = Math.ceil(HEIGHT / tileSize);
|
||||||
|
const halfTilesY = Math.ceil(requireTilesY / 2);
|
||||||
|
|
||||||
|
tileYCount = {
|
||||||
|
least: tileYCount.least - halfTilesY,
|
||||||
|
most: tileYCount.most + halfTilesY,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// タイル幅再計算
|
||||||
|
xSize = tileXCount.most - tileXCount.least + 1;
|
||||||
|
ySize = tileYCount.most - tileYCount.least + 1;
|
||||||
|
|
||||||
|
// 欠けているタイルを取得
|
||||||
|
for (let xIndex = 0; xIndex < xSize; xIndex++) {
|
||||||
|
const itX = xIndex + tileXCount.least;
|
||||||
|
|
||||||
|
for (let yIndex = 0; yIndex < ySize; yIndex++) {
|
||||||
|
const itY = yIndex + tileYCount.least;
|
||||||
|
|
||||||
|
const itTile = tiles.filter(tile => tile.tileX === itX && tile.tileY === itY)[0];
|
||||||
|
if (itTile)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
tiles.push({
|
||||||
|
tileX: itX,
|
||||||
|
tileY: itY,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// アセット
|
||||||
|
const assets: Record<string, {
|
||||||
|
buffer: Buffer<ArrayBuffer>;
|
||||||
|
size: number;
|
||||||
|
}> = {}
|
||||||
|
const assetsList = [
|
||||||
|
"scales/10",
|
||||||
|
"scales/20",
|
||||||
|
"scales/30",
|
||||||
|
"scales/40",
|
||||||
|
"scales/45",
|
||||||
|
"scales/46",
|
||||||
|
"scales/50",
|
||||||
|
"scales/55",
|
||||||
|
"scales/60",
|
||||||
|
"scales/70",
|
||||||
|
"epicenter",
|
||||||
|
];
|
||||||
|
|
||||||
|
await Promise.all(assetsList.map(async name => {
|
||||||
|
const key = name.split("/").at(-1);
|
||||||
|
if (!key)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const asset = readFileSync(`${import.meta.dirname}/assets/${name}.png`);
|
||||||
|
const assetMeta = await sharp(asset).metadata();
|
||||||
|
const assetSize = assetMeta.width;
|
||||||
|
|
||||||
|
assets[key] = {
|
||||||
|
buffer: asset,
|
||||||
|
size: assetSize,
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 各画像処理
|
||||||
|
await Promise.all(tiles.map(async (tile, index) => {
|
||||||
|
const TYPE = "blank";
|
||||||
|
const BASE_URL = "https://cyberjapandata.gsi.go.jp/xyz";
|
||||||
|
|
||||||
|
const request = await fetch(`${BASE_URL}/${TYPE}/${ZOOM_LEVEL}/${tile.tileX}/${tile.tileY}.png`);
|
||||||
|
|
||||||
|
if (!request.ok)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const tileImg = await sharp(await request.arrayBuffer()).resize({
|
||||||
|
width: tileSize,
|
||||||
|
height: tileSize,
|
||||||
|
}).png().toBuffer();
|
||||||
|
|
||||||
|
const top = tileSize * (tile.tileY - tileYCount.least);
|
||||||
|
const left = tileSize * (tile.tileX - tileXCount.least);
|
||||||
|
|
||||||
|
compounds.push({
|
||||||
|
type: "tile",
|
||||||
|
input: tileImg,
|
||||||
|
top,
|
||||||
|
left,
|
||||||
|
});
|
||||||
|
|
||||||
|
const itTilePositions = positions.filter(position => position.tileIndex === index);
|
||||||
|
itTilePositions.forEach(position => {
|
||||||
|
const requireAssetName = position.type === "epicenter"
|
||||||
|
? "epicenter"
|
||||||
|
: String(position.scale);
|
||||||
|
|
||||||
|
const asset = assets[requireAssetName];
|
||||||
|
if (!asset)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const tileResizedSize = tileSize / 256;
|
||||||
|
|
||||||
|
let about: any;
|
||||||
|
|
||||||
|
if (position.type === "epicenter") {
|
||||||
|
about = {
|
||||||
|
type: "epicenter",
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
about = {
|
||||||
|
type: "scale",
|
||||||
|
scale: position.scale,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
compounds.push({
|
||||||
|
...about,
|
||||||
|
input: asset.buffer,
|
||||||
|
top: Math.round(top + position.innerY * tileResizedSize - asset.size / 2),
|
||||||
|
left: Math.round(left + position.innerX * tileResizedSize - asset.size / 2),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
|
||||||
|
// compounds並び替え
|
||||||
|
const TYPE_PRIORITY: Record<string, number> = {
|
||||||
|
tile: 1,
|
||||||
|
scale: 2,
|
||||||
|
epicenter: 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
compounds = [...compounds].sort((a, b) => {
|
||||||
|
if (TYPE_PRIORITY[a.type] !== TYPE_PRIORITY[b.type]) {
|
||||||
|
return (TYPE_PRIORITY[a.type] ?? 99) - (TYPE_PRIORITY[b.type] ?? 99);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (a.type === "scale" && b.type === "scale") {
|
||||||
|
return a.scale - b.scale;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 案内部分
|
||||||
|
const informationPath = `${import.meta.dirname}/assets/information.png`;
|
||||||
|
const informationBuffer = readFileSync(informationPath);
|
||||||
|
const informationMeta = await sharp(informationPath).metadata();
|
||||||
|
const informationMargin = 20;
|
||||||
|
compounds.push({
|
||||||
|
type: "other",
|
||||||
|
input: informationBuffer,
|
||||||
|
top: HEIGHT - informationMeta.height - informationMargin,
|
||||||
|
left: WIDTH - informationMeta.width - informationMargin,
|
||||||
|
});
|
||||||
|
|
||||||
|
// クレジット部分
|
||||||
|
const creditPath = `${import.meta.dirname}/assets/credit.png`;
|
||||||
|
const creditBuffer = readFileSync(creditPath);
|
||||||
|
const creditMeta = await sharp(creditPath).metadata();
|
||||||
|
compounds.push({
|
||||||
|
type: "other",
|
||||||
|
input: creditBuffer,
|
||||||
|
top: HEIGHT - creditMeta.height,
|
||||||
|
left: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
result.composite(compounds);
|
||||||
|
|
||||||
|
const buffer = await result.png().toBuffer();
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
@@ -0,0 +1,360 @@
|
|||||||
|
import { createPost } from "@/lib/client";
|
||||||
|
import config from "@/lib/config";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { EOL } from "node:os";
|
||||||
|
import { WebSocket } from "ws";
|
||||||
|
import generateImage from "@/earthquake/generateImage";
|
||||||
|
|
||||||
|
if (config.earthquake?.useHistoryData) {
|
||||||
|
console.log("過去の地震情報を配信します");
|
||||||
|
const history = JSON.parse(readFileSync(`${import.meta.dirname}/../../260420.json`, "utf-8"));
|
||||||
|
history.reverse();
|
||||||
|
|
||||||
|
let i = 0;
|
||||||
|
setInterval(() => {
|
||||||
|
processMessage(history[i]);
|
||||||
|
i++;
|
||||||
|
}, 10 * 1000);
|
||||||
|
} else {
|
||||||
|
const connect = () => {
|
||||||
|
const WEBSOCKET_URL = config.debug
|
||||||
|
? "wss://api-realtime-sandbox.p2pquake.net/v2/ws"
|
||||||
|
: "wss://api.p2pquake.net/v2/ws";
|
||||||
|
|
||||||
|
console.log("P2P地震情報のWebSocketに接続します");
|
||||||
|
const socket = new WebSocket(WEBSOCKET_URL);
|
||||||
|
|
||||||
|
socket.addEventListener("open", () => {
|
||||||
|
console.log("P2P地震情報のWebSocketに接続しました");
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.addEventListener("close", (err) => {
|
||||||
|
const interval = config.earthquake.reconnectInterval;
|
||||||
|
console.log(`WebSocketが切断されました。${interval / 1000}秒後に再接続します:`, err.reason);
|
||||||
|
setTimeout(() => {
|
||||||
|
connect();
|
||||||
|
}, interval);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.addEventListener("error", (err) => {
|
||||||
|
console.error("WebSocketでエラーが発生しました:", err);
|
||||||
|
socket.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.addEventListener("message", async (event) => {
|
||||||
|
let message;
|
||||||
|
|
||||||
|
try {
|
||||||
|
message = typeof event.data === "string"
|
||||||
|
? JSON.parse(event.data)
|
||||||
|
: event.data;
|
||||||
|
} catch (err) {
|
||||||
|
console.error("地震情報メッセージのパースでエラーが発生:", err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = message.id ?? message._id;
|
||||||
|
const mem = Memory.memory;
|
||||||
|
if (mem.processedInfo.includes(id) && !config.debug) {
|
||||||
|
console.log("重複した地震情報:", message.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
processMessage(message);
|
||||||
|
|
||||||
|
if (!config.debug) {
|
||||||
|
const mem = Memory.memory;
|
||||||
|
mem.processedInfo = mem.processedInfo.concat([id]);
|
||||||
|
Memory.memory = mem;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
const processMessage = async (message: any) => {
|
||||||
|
try {
|
||||||
|
const scaleMessages: Record<string, string> = {
|
||||||
|
"-1": "不明",
|
||||||
|
"0": "震度0",
|
||||||
|
"10": "震度1",
|
||||||
|
"20": "震度2",
|
||||||
|
"30": "震度3",
|
||||||
|
"40": "震度4",
|
||||||
|
"45": "震度5弱",
|
||||||
|
"46": "**推定**震度5弱以上***(不明)***",
|
||||||
|
"50": "震度5強",
|
||||||
|
"55": "震度6弱",
|
||||||
|
"60": "震度6強",
|
||||||
|
"70": "震度7",
|
||||||
|
"99": "程度以上",
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (message.code) {
|
||||||
|
case 555:
|
||||||
|
console.log("ピアの地域分布情報を受信しました");
|
||||||
|
break;
|
||||||
|
case 551:
|
||||||
|
{
|
||||||
|
console.log("地震発生情報を受信しました");
|
||||||
|
|
||||||
|
if (
|
||||||
|
message.earthquake.maxScale !== -1 &&
|
||||||
|
message.earthquake.maxScale < config.earthquake.requireMaxScale
|
||||||
|
) {
|
||||||
|
console.log(`最大震度(${scaleMessages[message.earthquake.maxScale]})が投稿に必要な値(${scaleMessages[config.earthquake.requireMaxScale]})に満たないため、スキップします`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeMessage: Record<string, string> = {
|
||||||
|
"ScalePrompt": "震度速報",
|
||||||
|
"Destination": "震源情報",
|
||||||
|
}
|
||||||
|
|
||||||
|
const domesticTsunamiMessages: Record<string, string> = {
|
||||||
|
"None": "😌この地震による**国内**の津波の心配はありません。",
|
||||||
|
"Unknown": "😕この地震による**国内**の***津波情報は***不明です。",
|
||||||
|
"Checking": "🧐この地震による**国内**の津波情報を**調査中です。**",
|
||||||
|
"NonEffective": "😌この地震による**国内**の**海面変動が予想されますが**、被害の心配はありません。",
|
||||||
|
"Watch": "⚠️この地震により**国内**で津波注意報が発令しています。",
|
||||||
|
"Warning": "🚨この地震による**国内**の津波予報があります。",
|
||||||
|
}
|
||||||
|
|
||||||
|
const foreignTsunamiMessages: Record<string, string> = {
|
||||||
|
"None": "😌この地震による**国外**の津波の心配はありません。",
|
||||||
|
"Unknown": "😕この地震による**国外**の***津波情報は***不明です。",
|
||||||
|
"Checking": "🧐この地震による**国外**の津波情報を**調査中です。**",
|
||||||
|
"NonEffectiveNearby": "😌この地震によって**国外**にて震源の近傍で**小さな津波の可能性はありますが**、被害の心配はありません。",
|
||||||
|
"WarningNearby": "⚠️この地震によって**国外**にて震源の近傍で**津波の可能性**があります。",
|
||||||
|
"WarningPacific": "⚠️この地震によって**太平洋**にて**津波の可能性**があります。",
|
||||||
|
"WarningPacificWide": "🚨この地震によって**太平洋の広域**にて**津波の可能性**があります。",
|
||||||
|
"WarningIndian": "⚠️この地震によって**インド洋**にて**津波の可能性**があります。",
|
||||||
|
"WarningIndianWide": "🚨この地震によって**インド洋の広域**にて**津波の可能性**があります。",
|
||||||
|
"Potential": "🚨この地震によって**一般的に**この規模では津波の可能性があると考えられています。",
|
||||||
|
}
|
||||||
|
|
||||||
|
const grouped: Record<string, { scale: number; prefs: Record<string, string[]> }> = {};
|
||||||
|
|
||||||
|
for (const point of message.points) {
|
||||||
|
const { addr, scale, pref } = point;
|
||||||
|
const label = scaleMessages[String(scale)] ?? "不明";
|
||||||
|
|
||||||
|
if (!grouped[label]) {
|
||||||
|
grouped[label] = { scale, prefs: {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!grouped[label].prefs[pref]) {
|
||||||
|
grouped[label].prefs[pref] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
grouped[label].prefs[pref].push(addr);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pointsMsg = Object.entries(grouped)
|
||||||
|
.sort((a, b) => b[1].scale - a[1].scale)
|
||||||
|
.map(([label, { prefs }]) => {
|
||||||
|
const prefLines = Object.entries(prefs)
|
||||||
|
.map(([pref, addrs]) => `**${pref}:** ${addrs.join("・")}`)
|
||||||
|
.join(EOL);
|
||||||
|
|
||||||
|
return `【${label}】${EOL}${prefLines}`;
|
||||||
|
})
|
||||||
|
.join(EOL.repeat(2))
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
const earthquakeNoticeText = [];
|
||||||
|
earthquakeNoticeText.push(`### ${typeMessage[message.issue.type] ?? "地震情報"}`);
|
||||||
|
earthquakeNoticeText.push(`⏰時刻: ${format(new Date(message.earthquake.time), "yyyy年M月d日 H:mm")}頃`);
|
||||||
|
earthquakeNoticeText.push(...(message.earthquake.maxScale === -1
|
||||||
|
? []
|
||||||
|
: [`🫨最大震度: ${scaleMessages[String(message.earthquake.maxScale)]}`]));
|
||||||
|
earthquakeNoticeText.push(...(message.earthquake.hypocenter.name === ""
|
||||||
|
? []
|
||||||
|
: [`📍震源地: ${message.earthquake.hypocenter.name}`]));
|
||||||
|
earthquakeNoticeText.push(...(message.earthquake.hypocenter.magnitude === -1
|
||||||
|
? []
|
||||||
|
: [`💪マグニチュード: M${message.earthquake.hypocenter.magnitude.toFixed(1)}`]));
|
||||||
|
earthquakeNoticeText.push(...(message.earthquake.hypocenter.depth === -1
|
||||||
|
? []
|
||||||
|
: [`🪨深さ: ${message.earthquake.hypocenter.depth === 0
|
||||||
|
? "ごく浅い"
|
||||||
|
: `${message.earthquake.hypocenter.depth}km`}`]));
|
||||||
|
earthquakeNoticeText.push(...(message.earthquake.domesticTsunami ?? "Unknown" === "Unknown"
|
||||||
|
? []
|
||||||
|
: [domesticTsunamiMessages[(message.earthquake.domesticTsunami)]]));
|
||||||
|
earthquakeNoticeText.push(...(message.earthquake.foreignTsunami ?? "Unknown" === "Unknown"
|
||||||
|
? []
|
||||||
|
: [foreignTsunamiMessages[(message.earthquake.foreignTsunami)]]));
|
||||||
|
earthquakeNoticeText.push(...(pointsMsg === ""
|
||||||
|
? []
|
||||||
|
: [EOL + pointsMsg]));
|
||||||
|
earthquakeNoticeText.push(...(message.comments.freeFormComment === ""
|
||||||
|
? []
|
||||||
|
: [message.comments.freeFormComment]));
|
||||||
|
earthquakeNoticeText.push(EOL + `🔬情報源: P2P地震速報 - ${message.issue.source ?? "不明"}`);
|
||||||
|
const earthquakePosts = await createPost({
|
||||||
|
text: earthquakeNoticeText.join(EOL),
|
||||||
|
}, "地震発生情報");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const image = await generateImage(message);
|
||||||
|
|
||||||
|
if (typeof image === "string") {
|
||||||
|
throw "情報が不足しているため、地震の画像生成ができませんでした";
|
||||||
|
} else {
|
||||||
|
const infoPostURL = earthquakePosts[0]?.data.id
|
||||||
|
? `https://collapse.jp/@${config.mtweet.userid}/${earthquakePosts[0].data.id}`
|
||||||
|
: "不明";
|
||||||
|
|
||||||
|
const earthquakeImageText = [];
|
||||||
|
earthquakeImageText.push("地震の震度分布画像を生成しました。");
|
||||||
|
earthquakeImageText.push(`地震情報投稿: ${infoPostURL}`);
|
||||||
|
await createPost({
|
||||||
|
text: earthquakeImageText.join(EOL),
|
||||||
|
images: [
|
||||||
|
[
|
||||||
|
new Blob([new Uint8Array(image)], { type: "image/png" }),
|
||||||
|
"earthquake.png",
|
||||||
|
],
|
||||||
|
],
|
||||||
|
}, "震度分布画像");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 552:
|
||||||
|
{
|
||||||
|
console.log("津波予報情報を受信しました");
|
||||||
|
|
||||||
|
if (message.cancelled) {
|
||||||
|
const tsunamiCancelledNoticeText = [];
|
||||||
|
tsunamiCancelledNoticeText.push("### 津波予報**解除**");
|
||||||
|
tsunamiCancelledNoticeText.push(`⏰発表時刻: ${format(new Date(message.issue.time), "yyyy年M月d日 H:mm:ss")}`);
|
||||||
|
tsunamiCancelledNoticeText.push(`🔬情報源: P2P地震速報 - ${message.issue.source ?? "不明"}`);
|
||||||
|
await createPost({
|
||||||
|
text: tsunamiCancelledNoticeText.join(EOL),
|
||||||
|
}, "津波予報解除情報");
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const gradeMessages: Record<string, string> = {
|
||||||
|
"Unknown": "不明",
|
||||||
|
"Watch": "津波注意報",
|
||||||
|
"Warning": "津波警報",
|
||||||
|
"MajorWarning": "大津波警報",
|
||||||
|
}
|
||||||
|
|
||||||
|
let areasMsg = "";
|
||||||
|
for (const area of message.areas) {
|
||||||
|
const tsunamiAreaText = [];
|
||||||
|
tsunamiAreaText.push(`【${area.name}】`);
|
||||||
|
tsunamiAreaText.push(...(area.immediate
|
||||||
|
? ["🚨***直ちに津波が来襲すると予想されています。***"]
|
||||||
|
: []));
|
||||||
|
tsunamiAreaText.push(`🏷️種別: ${gradeMessages[area.grade]}`);
|
||||||
|
tsunamiAreaText.push(`⏳第1波到達予想時刻: ${format(new Date(area.firstHeight.arrivalTime), "yyyy年M月d日 H:mm")}`);
|
||||||
|
tsunamiAreaText.push(`🌊第1波の状態: ${area.firstHeight.condition
|
||||||
|
? `${area.firstHeight.condition}されています`
|
||||||
|
: "不明"}`);
|
||||||
|
tsunamiAreaText.push(`🗼予想される津波の高さ: ${area.maxHeight.value === 0.2
|
||||||
|
? "0.2m未満"
|
||||||
|
: (area.maxHeight.value
|
||||||
|
? `${area.maxHeight.value}m`
|
||||||
|
: area.maxHeight.description)}`);
|
||||||
|
areasMsg += tsunamiAreaText.join(EOL) + EOL.repeat(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tsunamiNoticeText = [];
|
||||||
|
tsunamiNoticeText.push("### 津波予報**発表**");
|
||||||
|
tsunamiNoticeText.push(`⏰発表時刻: ${format(new Date(message.issue.time), "yyyy年M月d日 H:mm:ss")}`);
|
||||||
|
tsunamiNoticeText.push(EOL + areasMsg.trim() + EOL);
|
||||||
|
tsunamiNoticeText.push(`🔬情報源: P2P地震速報 - message.issue.source ?? "不明"`);
|
||||||
|
await createPost({
|
||||||
|
text: tsunamiNoticeText.join(EOL),
|
||||||
|
}, "津波予報情報");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 556:
|
||||||
|
{
|
||||||
|
console.log("緊急地震速報(警報)を受信しました");
|
||||||
|
|
||||||
|
if (message.cancelled) {
|
||||||
|
const eewCancelledNotice = [];
|
||||||
|
eewCancelledNotice.push("### 緊急地震速報(警報)**解除**");
|
||||||
|
eewCancelledNotice.push(message.test
|
||||||
|
? "⚒️これは**テストです。**"
|
||||||
|
: "🚨これは**テストではありません。**");
|
||||||
|
eewCancelledNotice.push(`⏰発表時刻: ${format(new Date(message.issue.time), "yyyy年M月d日 H:mm:ss")}`);
|
||||||
|
eewCancelledNotice.push(EOL + "🔬情報源: P2P地震速報");
|
||||||
|
await createPost({
|
||||||
|
text: eewCancelledNotice.join(EOL),
|
||||||
|
}, "緊急地震速報(警報)解除情報");
|
||||||
|
}
|
||||||
|
|
||||||
|
const kindMessages: Record<string, string> = {
|
||||||
|
"10": "⏳主要動は、**未到達と予測**されています。",
|
||||||
|
"11": "🫨主要動が、**既に到達していると予測**されています。",
|
||||||
|
"19": "🧐PLUM法によると、主要動の***到達予想は***ありません。",
|
||||||
|
}
|
||||||
|
|
||||||
|
let areasMsg = "";
|
||||||
|
for (const area of message.areas) {
|
||||||
|
const eewAreaText = [];
|
||||||
|
eewAreaText.push(`【${area.name}】`);
|
||||||
|
eewAreaText.push(`🫨最大予測震度: ${scaleMessages[String(Math.floor(area.scaleFrom))] +
|
||||||
|
(area.scaleTo === 99
|
||||||
|
? "程度以上"
|
||||||
|
: area.scaleFrom !== area.scaleTo
|
||||||
|
? `から${scaleMessages[String(Math.floor(area.scaleTo))]}`
|
||||||
|
: "")}`);
|
||||||
|
eewAreaText.push(`⏰到達予想時刻: ${typeof area.arrivalTime === "string" && area.arrivalTime !== ""
|
||||||
|
? format(new Date(area.arrivalTime), "yyyy年M月d日 H:mm:ss")
|
||||||
|
: "不明"}`);
|
||||||
|
eewAreaText.push(kindMessages[area.kindCode] ?? "😕主要動の***到達予想は***ありません。");
|
||||||
|
|
||||||
|
areasMsg += eewAreaText.join(EOL) + EOL.repeat(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
const eewNoticeText = [];
|
||||||
|
eewNoticeText.push("### ***緊急地震速報(警報)***");
|
||||||
|
eewNoticeText.push(message.test
|
||||||
|
? "⚒️これは**テストです。**"
|
||||||
|
: "🚨これは**テストではありません。**");
|
||||||
|
eewNoticeText.push(...(message.earthquake.condition === "仮定震源要素"
|
||||||
|
? ["❓これは、仮定震源要素です。そのため、震源に関する情報が保証できません。"]
|
||||||
|
: []));
|
||||||
|
eewNoticeText.push(`⏰発表時刻: ${format(new Date(message.issue.time), "yyyy年M月d日 H:mm:ss")}`);
|
||||||
|
eewNoticeText.push(`⏰地震発生時刻: ${format(new Date(message.earthquake.originTime), "yyyy年M月d日 H:mm:ss")}`);
|
||||||
|
eewNoticeText.push(`⏰地震発現時刻: ${format(new Date(message.earthquake.arrivalTime), "yyyy年M月d日 H:mm:ss")}`);
|
||||||
|
eewNoticeText.push(`📍震源地: ${message.earthquake.hypocenter.name ?? "不明"}`);
|
||||||
|
eewNoticeText.push(`💪マグニチュード: ${message.earthquake.hypocenter.magnitude === undefined ||
|
||||||
|
message.earthquake.hypocenter.magnitude === -1
|
||||||
|
? "不明"
|
||||||
|
: `M${message.earthquake.hypocenter.magnitude.toFixed(1)}`}`);
|
||||||
|
eewNoticeText.push(`🪨深さ: ${message.earthquake.hypocenter.depth === undefined ||
|
||||||
|
message.earthquake.hypocenter.depth === -1
|
||||||
|
? "不明"
|
||||||
|
: `${Math.floor(message.earthquake.hypocenter.depth)}km`}`);
|
||||||
|
eewNoticeText.push(...(areasMsg !== ""
|
||||||
|
? [EOL + areasMsg.trim()]
|
||||||
|
: []));
|
||||||
|
eewNoticeText.push(EOL + "🔬情報源: P2P地震速報");
|
||||||
|
|
||||||
|
await createPost({
|
||||||
|
text: eewNoticeText.join(EOL),
|
||||||
|
}, "緊急地震速報(警報)情報");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
console.log("未対応の情報:", message);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("メッセージの処理に失敗しました:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { schedule } from "node-cron";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import config from "@/lib/config";
|
||||||
|
import { initData } from "@/lib/memory";
|
||||||
|
import { styleText } from "node:util";
|
||||||
|
import { Worker } from "node:worker_threads";
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log(readFileSync(`${import.meta.dirname}/../asciiart.txt`, "utf-8"));
|
||||||
|
console.log(JSON.parse(readFileSync(`${import.meta.dirname}/../package.json`, "utf-8")).version);
|
||||||
|
if (config.debug) {
|
||||||
|
process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
|
||||||
|
|
||||||
|
console.log(styleText(
|
||||||
|
["bgRed", "cyan", "bold"],
|
||||||
|
"デバッグモードが有効です",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
console.log();
|
||||||
|
|
||||||
|
await initData();
|
||||||
|
|
||||||
|
new Worker(`${import.meta.dirname}/feature/earthquake/index.js`);
|
||||||
|
|
||||||
|
console.log("Botが起動しました");
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("message" in err
|
||||||
|
? err.message
|
||||||
|
: err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
schedule("0 * * * *", async () => {
|
||||||
|
new Worker(`${import.meta.dirname}/feature/time/index.js`);
|
||||||
|
});
|
||||||
|
|
||||||
|
schedule("0 7 * * *", async () => {
|
||||||
|
new Worker(`${import.meta.dirname}/feature/weatherNotice.js`, {
|
||||||
|
workerData: "scheduledWeatherNotice",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
schedule("0 18 * * *", async () => {
|
||||||
|
new Worker(`${import.meta.dirname}/feature/weatherNotice.js`, {
|
||||||
|
workerData: "scheduledWeatherNoticeTomorrow",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
schedule(`*/${config.command.interval} * * * *`, async () => {
|
||||||
|
new Worker(`${import.meta.dirname}/feature/command/index.js`);
|
||||||
|
});
|
||||||
|
|
||||||
|
let hnyWorker: Worker | undefined = undefined;
|
||||||
|
|
||||||
|
schedule("57 59 23 31 12 *", () => {
|
||||||
|
hnyWorker = new Worker(`${import.meta.dirname}/feature/hnyNotice.js`);
|
||||||
|
});
|
||||||
|
|
||||||
|
schedule("0 0 0 1 1 *", () => {
|
||||||
|
hnyWorker?.postMessage("");
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("message" in err
|
||||||
|
? err.message
|
||||||
|
: err);
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import config from "@/lib/config";
|
||||||
|
import { EOL } from "node:os";
|
||||||
|
|
||||||
|
type SuccessPosted<T = string> = {
|
||||||
|
ok: true;
|
||||||
|
data: {
|
||||||
|
status: "SUCCESS";
|
||||||
|
id: T;
|
||||||
|
source: "api";
|
||||||
|
deduped: boolean;
|
||||||
|
post: {
|
||||||
|
id: T;
|
||||||
|
content: string;
|
||||||
|
zone: "normal" | string;
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const BASE_URL = "https://collapse.jp/api/v3/bot";
|
||||||
|
|
||||||
|
export const createPost = async (data: {
|
||||||
|
text: string;
|
||||||
|
zone?: "normal" | string;
|
||||||
|
replyTarget?: "all" | string;
|
||||||
|
replyid?: string;
|
||||||
|
images?: [Blob, string][];
|
||||||
|
}, title: string) => {
|
||||||
|
const excessedMessage = "**👉返信に続きがあります。**";
|
||||||
|
|
||||||
|
let lines = data.text.split(EOL);
|
||||||
|
let firstUniqid = "";
|
||||||
|
let count = 0;
|
||||||
|
|
||||||
|
const results: SuccessPosted[] = [];
|
||||||
|
|
||||||
|
while (lines.length > 0) {
|
||||||
|
count++;
|
||||||
|
let currentText = "";
|
||||||
|
|
||||||
|
const maxLength = config.post.maxLength;
|
||||||
|
|
||||||
|
const limit = maxLength - (excessedMessage.length + EOL.length * 2);
|
||||||
|
|
||||||
|
while (lines.length > 0) {
|
||||||
|
const nextLine = lines[0];
|
||||||
|
if (nextLine === undefined)
|
||||||
|
break;
|
||||||
|
|
||||||
|
if (nextLine.length > limit && currentText === "") {
|
||||||
|
const targetLine = lines.shift() || "";
|
||||||
|
currentText = targetLine.slice(0, limit);
|
||||||
|
|
||||||
|
lines.unshift(targetLine.slice(limit));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const potentialText = currentText ? currentText + EOL + nextLine : nextLine;
|
||||||
|
if (potentialText.length <= limit) {
|
||||||
|
currentText = potentialText;
|
||||||
|
lines.shift();
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
currentText = currentText.trimEnd();
|
||||||
|
|
||||||
|
if (lines.length > 0) {
|
||||||
|
currentText += EOL.repeat(2) + excessedMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
let postedUniqid = "";
|
||||||
|
let success = false;
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= config.post.maxRetries; attempt++) {
|
||||||
|
try {
|
||||||
|
const replyid = data.replyid === undefined && firstUniqid !== ""
|
||||||
|
? firstUniqid
|
||||||
|
: data.replyid;
|
||||||
|
const reply = replyid !== undefined
|
||||||
|
? `/${replyid}/reply`
|
||||||
|
: "";
|
||||||
|
const url = new URL(`/posts${reply}`, BASE_URL);
|
||||||
|
|
||||||
|
const body = new FormData();
|
||||||
|
body.append("content", data.text);
|
||||||
|
if (!replyid) {
|
||||||
|
body.append("zone", data.zone ?? "normal");
|
||||||
|
body.append("reply_restriction", data.replyTarget ?? "all");
|
||||||
|
}
|
||||||
|
if (data.images) {
|
||||||
|
data.images.forEach(img => body.append("images", img[0], img[1]));
|
||||||
|
}
|
||||||
|
|
||||||
|
const req = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
content: data.text,
|
||||||
|
zone: data.zone ?? "normal",
|
||||||
|
reply_restriction: data.replyTarget ?? "all",
|
||||||
|
}),
|
||||||
|
headers: {
|
||||||
|
"Authorization": `Bearer ${config.mtweet.token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await req.json();
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
success = true;
|
||||||
|
postedUniqid = res.data.id;
|
||||||
|
results.push(res);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.warn(`${title}の投稿に失敗しました (試行 ${attempt}/${config.post.maxRetries}):`, res.error.message);
|
||||||
|
if (attempt < config.post.maxRetries) {
|
||||||
|
const interval = res.error.details?.retry_after
|
||||||
|
? res.error.details.retry_after * 1000
|
||||||
|
: config.post.retryInterval;
|
||||||
|
await new Promise(resolve => setTimeout(resolve, interval));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
console.error(`${title}の全試行が失敗したため、処理を中断します。`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (firstUniqid === "")
|
||||||
|
firstUniqid = postedUniqid;
|
||||||
|
|
||||||
|
console.log(`${title}を投稿(${count}):`, postedUniqid);
|
||||||
|
|
||||||
|
while (lines.length > 0 && lines[0]?.trim() === "") {
|
||||||
|
lines.shift();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import z from "zod";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { parse as yamlParse } from "yaml";
|
||||||
|
import { EOL } from "node:os";
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
earthquake: z.object({
|
||||||
|
requireMaxScale: z.union([
|
||||||
|
z.literal(10),
|
||||||
|
z.literal(20),
|
||||||
|
z.literal(30),
|
||||||
|
z.literal(40),
|
||||||
|
z.literal(45),
|
||||||
|
z.literal(50),
|
||||||
|
z.literal(55),
|
||||||
|
z.literal(60),
|
||||||
|
z.literal(70),
|
||||||
|
]),
|
||||||
|
useHistoryData: z.boolean(),
|
||||||
|
reconnectInterval: z.number().positive(),
|
||||||
|
}),
|
||||||
|
mtweet: z.object({
|
||||||
|
token: z.string().length(64),
|
||||||
|
userid: z.string().min(1),
|
||||||
|
}),
|
||||||
|
post: z.object({
|
||||||
|
maxRetries: z.number().int().positive(),
|
||||||
|
retryInterval: z.number().positive(),
|
||||||
|
maxLength: z.number().int().nonnegative().max(10000),
|
||||||
|
}),
|
||||||
|
debug: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const configFile = readFileSync(`${import.meta.dirname}/../../config/config.yaml`, "utf-8");
|
||||||
|
const configObj = yamlParse(configFile);
|
||||||
|
const result = schema.safeParse(configObj);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
console.error("Config: configが無効です。");
|
||||||
|
console.error(` ${result.error.issues.map(issue => issue.message).join(EOL).replaceAll(EOL, `${EOL} `)}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = result.data;
|
||||||
|
export default config;
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import i18next from "i18next";
|
||||||
|
import config from "@/lib/config";
|
||||||
|
import { parse as yamlParse } from "yaml";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
|
||||||
|
const translation = Object.fromEntries(Object.entries(
|
||||||
|
yamlParse(readFileSync(`${import.meta.dirname}/../../locales/ja.yaml`, "utf-8"))
|
||||||
|
).map(([key, value]) => [
|
||||||
|
key,
|
||||||
|
typeof value === "string"
|
||||||
|
? value.trim()
|
||||||
|
: value,
|
||||||
|
]));
|
||||||
|
|
||||||
|
export default async function initI18n() {
|
||||||
|
await i18next.init({
|
||||||
|
lng: "ja",
|
||||||
|
debug: config.debug,
|
||||||
|
resources: {
|
||||||
|
ja: {
|
||||||
|
translation,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
interpolation: {
|
||||||
|
escapeValue: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2024",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"outDir": "./dist",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"baseUrl": "./src",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./*"],
|
||||||
|
},
|
||||||
|
"removeComments": true,
|
||||||
|
},
|
||||||
|
"tsc-alias": {
|
||||||
|
"resolveFullPaths": true,
|
||||||
|
},
|
||||||
|
}
|
||||||