diff --git a/docs/registry-format.md b/docs/registry-format.md
index 501b383..375e601 100644
--- a/docs/registry-format.md
+++ b/docs/registry-format.md
@@ -159,6 +159,10 @@ public/registry/plugins/my-plugin/
try/catch は無いので、失敗しうる呼び出しは事前確認で避ける(例: `meta` の
`translatorAvailable` を見てから `notes/translate`)。`Core:type(res) == "error"` 判定は
本家互換のために残してよい([notedeck#1249](https://github.com/notedeck-dev/notedeck/issues/1249))
+- `Nd:http` の通信断(DNS 失敗・接続拒否・タイムアウト)は throw ではなく `status: 0` +
+ `error` の値で返る([notedeck#1294](https://github.com/notedeck-dev/notedeck/issues/1294))。
+ HTTP の 4xx / 5xx と同じ `status` 分岐に乗せ、`res.status == 0` で「通信できません」を描く。
+ `Nd:call("http.fetch")` / `Nd:call("vault.fetch")` も同形
- `Nd:call` が `error` 値を返すのは確認ダイアログのキャンセル(`user_cancelled`)だけ。
権限拒否は throw するので、既定プロファイルで許可されていない capability
(`account.write` / `styles.write` / `network.external` 等)を使うなら
diff --git a/public/registry/index.json b/public/registry/index.json
index e315cfb..3c53a86 100644
--- a/public/registry/index.json
+++ b/public/registry/index.json
@@ -1,24 +1,24 @@
{
"version": 1,
- "updatedAt": "2026-10-28T23:08:30.161Z",
+ "updatedAt": "2026-10-29T19:40:04.494Z",
"plugins": {
"count": 43,
- "updatedAt": "2026-10-28T23:08:30.161Z"
+ "updatedAt": "2026-10-29T19:40:04.494Z"
},
"themes": {
"count": 77,
- "updatedAt": "2026-10-28T23:08:30.161Z"
+ "updatedAt": "2026-10-29T19:40:04.494Z"
},
"widgets": {
"count": 70,
- "updatedAt": "2026-10-28T23:08:30.161Z"
+ "updatedAt": "2026-10-29T19:40:04.494Z"
},
"skills": {
"count": 44,
- "updatedAt": "2026-10-28T23:08:30.161Z"
+ "updatedAt": "2026-10-29T19:40:04.494Z"
},
"queries": {
"count": 35,
- "updatedAt": "2026-10-28T23:08:30.161Z"
+ "updatedAt": "2026-10-29T19:40:04.494Z"
}
}
diff --git a/public/registry/plugins.json b/public/registry/plugins.json
index a70ffbc..0f12b11 100644
--- a/public/registry/plugins.json
+++ b/public/registry/plugins.json
@@ -1,6 +1,6 @@
{
"version": 1,
- "updatedAt": "2026-10-28T23:08:30.161Z",
+ "updatedAt": "2026-10-29T19:40:04.494Z",
"plugins": [
{
"id": "ai-actions",
@@ -338,7 +338,7 @@
],
"sourceUrl": "https://store.notedeck.io/registry/plugins/url-safety-check/plugin.is",
"apiUrl": "https://store.notedeck.io/registry/plugins/url-safety-check/api.json",
- "sha512": "9e7561efeb8441d3bf360d05c85156c205a99572b66eefd12767fb52c19c93a4175e3c3a81c22b79f7a60abcfeb6d81c62c656e3a81feb3f6ffcfe5f7437d46b",
+ "sha512": "461a8a39058c16cd51c333fd8ce071191ab86b698a3249305d8ec127c693293505fa6586742883373617fdab7017a2b2ab8364ed849f91e78942967dca380c4a",
"createdAt": "2026-07-14T15:09:18+09:00",
"updatedAt": "2026-09-25T18:20:03+09:00",
"authorUrl": "https://github.com/hitalin",
@@ -1934,7 +1934,7 @@
"apiUrl": "https://store.notedeck.io/registry/plugins/tech-autolink/api.json",
"sha512": "e5e2d778c1b30c7a34bd4de6cc3a34f88b839887794f569f3989e04723bfa1b981c3c50a275e14d0538bde393f88dc9344f4d5f3a44fe1670ab5ab60439c56ec",
"createdAt": "2026-07-31T15:01:56+09:00",
- "updatedAt": "2026-10-29T07:57:04+09:00",
+ "updatedAt": "2026-10-29T08:08:37+09:00",
"authorUrl": "https://github.com/hitalin",
"license": "MIT",
"locales": {
diff --git a/public/registry/plugins/url-safety-check/api.json b/public/registry/plugins/url-safety-check/api.json
index 87312a9..034c616 100644
--- a/public/registry/plugins/url-safety-check/api.json
+++ b/public/registry/plugins/url-safety-check/api.json
@@ -1 +1 @@
-{"type":"plugin","data":"/// @ 1.2.1\n\n// URL 安全チェック — ノート内の URL をキー不要の脅威 DB で照合\n//\n// ノートメニューから本文中の URL を抽出し、以下の 3 ソースで照合する。\n// いずれも API キー不要 (Vault 不要、vault.fetch の plugin 開放 #759 に依存しない):\n// - Cloudflare Malware DNS (security.cloudflare-dns.com):\n// 脅威リスト掲載ドメインは 0.0.0.0 に censored されるのを判定に使う\n// - PhishTank (checkurl.phishtank.com): フィッシング URL データベース照合\n// - Sucuri SiteCheck (sitecheck.sucuri.net): ブラックリスト照合 + 総合評価\n//\n// Nd:http は呼び出しごとに確認ダイアログが出る (network.external)。\n// 「今後この操作を確認しない」で以降はスキップされる。\n// AiScript に try/catch は無いため、いずれかのサービスがネットワーク的に\n// 落ちている場合はハンドラごと停止する (HTTP エラーステータスは処理する)。\n\n### {\n\tname: \"URL 安全チェック\"\n\tversion: \"1.0.1\"\n\tauthor: \"@hitalin\"\n\tdescription: \"ノート本文の URL を Cloudflare Malware DNS / PhishTank / Sucuri SiteCheck で照合し、フィッシング・マルウェアの兆候を確認します。チェック時に URL が各サービスに送信されます。API キーは不要です\"\n\tpermissions: [\"network.external\"]\n\tconfig: {\n\t\tcheckSucuri: {\n\t\t\ttype: \"boolean\"\n\t\t\tlabel: \"Sucuri SiteCheck も実行する\"\n\t\t\tdescription: \"ブラックリスト照合と総合評価。未スキャンのドメインは応答に時間がかかることがあります\"\n\t\t\tdefault: true\n\t\t}\n\t\tmaxUrls: {\n\t\t\ttype: \"number\"\n\t\t\tlabel: \"1回にチェックする URL の上限\"\n\t\t\tdefault: 3\n\t\t}\n\t}\n}\n\nlet trailingClosers = [\")\", \")\", \"」\", \"]\", \",\", \"、\", \"。\", \"\\\"\", \">\"]\n\n// 文末で URL に括弧や句読点が続くケース (「(https://...)」等) の末尾記号を剥がす\n@stripTail(token) {\n\tvar body = token\n\tloop {\n\t\tif (body.len == 0) break\n\t\tlet last = body.slice(body.len - 1, body.len)\n\t\tif (!trailingClosers.incl(last)) break\n\t\tbody = body.slice(0, body.len - 1)\n\t}\n\tbody\n}\n\n@extractUrls(text) {\n\tlet normalized = text.replace(\" \", Str:lf).replace(\" \", Str:lf)\n\tvar urls = []\n\teach (let token, normalized.split(Str:lf)) {\n\t\tvar hIdx = token.index_of(\"https://\")\n\t\tif (hIdx == -1) hIdx = token.index_of(\"http://\")\n\t\tif (hIdx != -1) {\n\t\t\tlet url = stripTail(token.slice(hIdx, token.len))\n\t\t\tif (url.index_of(\"://\") != -1 && !urls.incl(url)) {\n\t\t\t\turls.push(url)\n\t\t\t}\n\t\t}\n\t}\n\turls\n}\n\n@extractDomain(url) {\n\tlet idx = url.index_of(\"://\")\n\tif (idx == -1) return null\n\tvar rest = url.slice(idx + 3, url.len)\n\teach (let sep, [\"/\", \"?\", \"#\"]) {\n\t\tlet i = rest.index_of(sep)\n\t\tif (i != -1) rest = rest.slice(0, i)\n\t}\n\tlet atIdx = rest.index_of(\"@\")\n\tif (atIdx != -1) rest = rest.slice(atIdx + 1, rest.len)\n\tlet colonIdx = rest.index_of(\":\")\n\tif (colonIdx != -1) rest = rest.slice(0, colonIdx)\n\tif (rest == \"\") return null\n\trest\n}\n\n// Cloudflare Malware DNS (1.1.1.2 相当の DoH)。脅威リスト掲載ドメインは\n// 0.0.0.0 への censored 応答になる\n@checkCloudflare(domain) {\n\tlet res = Nd:http(`https://security.cloudflare-dns.com/dns-query?name={Uri:encode_component(domain)}&type=A`, {\n\t\tmethod: \"GET\"\n\t\theaders: { \"accept\": \"application/dns-json\" }\n\t})\n\tif (res.status != 200) return `❓ 取得失敗 (HTTP {res.status})`\n\tlet data = Json:parse(res.body)\n\tvar blocked = false\n\tif (data.Answer != null) {\n\t\teach (let a, data.Answer) {\n\t\t\tif (a.data == \"0.0.0.0\") blocked = true\n\t\t}\n\t}\n\tif (blocked) return \"🚫 危険 — 脅威リストでブロックされています\"\n\tif (data.Status == 3) return \"❓ ドメインが存在しません\"\n\t\"✅ 検出なし\"\n}\n\n@checkPhishtank(url) {\n\t// PhishTank は phishtank/ prefix の User-Agent を要求する (それ以外は 403)\n\tlet res = Nd:http(\"https://checkurl.phishtank.com/checkurl/\", {\n\t\tmethod: \"POST\"\n\t\theaders: {\n\t\t\t\"content-type\": \"application/x-www-form-urlencoded\"\n\t\t\t\"user-agent\": \"phishtank/notedeck\"\n\t\t}\n\t\tbody: `url={Uri:encode_component(url)}&format=json`\n\t})\n\tif (res.status != 200) return `❓ 取得失敗 (HTTP {res.status})`\n\tlet data = Json:parse(res.body)\n\tif (data.results == null) return \"❓ 応答を解釈できませんでした\"\n\tif (data.results.in_database == true && data.results.valid == true) {\n\t\treturn \"🚨 危険 — フィッシングサイトとして登録済み\"\n\t}\n\t\"✅ 検出なし\"\n}\n\n@checkSucuri(domain) {\n\tlet res = Nd:http(`https://sitecheck.sucuri.net/api/v3/?scan={Uri:encode_component(domain)}`, {\n\t\tmethod: \"GET\"\n\t\ttimeoutMs: 60000\n\t})\n\tif (res.status != 200) return `❓ 取得失敗 (HTTP {res.status})`\n\tlet data = Json:parse(res.body)\n\tvar parts = []\n\tif (data.ratings != null && data.ratings.total != null) {\n\t\tparts.push(`総合評価 {data.ratings.total.rating}`)\n\t}\n\tif (data.warnings != null) {\n\t\teach (let kv, Obj:kvs(data.warnings)) {\n\t\t\tlet key = kv[0]\n\t\t\tlet n = if (Core:type(kv[1]) == \"arr\") kv[1].len else 1\n\t\t\tif (key == \"blacklist\") {\n\t\t\t\tparts.push(`🚨 ブラックリスト掲載 {n}件`)\n\t\t\t} elif (key == \"scan_failed\") {\n\t\t\t\tparts.push(\"スキャン未完了\")\n\t\t\t} else {\n\t\t\t\tparts.push(`⚠️ 警告 ({key}) {n}件`)\n\t\t\t}\n\t\t}\n\t}\n\tif (parts.len == 0) return \"✅ 警告なし\"\n\tparts.join(\" / \")\n}\n\nPlugin:register_note_action(\"URL の安全性をチェック\", @(note) {\n\tif (note.text == null) {\n\t\tMk:dialog(\"URL 安全チェック\", \"テキストのないノートです\", \"info\")\n\t\treturn null\n\t}\n\tlet urls = extractUrls(note.text)\n\tif (urls.len == 0) {\n\t\tMk:dialog(\"URL 安全チェック\", \"ノートに URL が見つかりませんでした\", \"info\")\n\t\treturn null\n\t}\n\tvar targets = []\n\teach (let url, urls) {\n\t\tif (targets.len < Plugin:config.maxUrls) targets.push(url)\n\t}\n\n\tvar lines = []\n\tvar flaggedDomain = null\n\teach (let url, targets) {\n\t\tlet domain = extractDomain(url)\n\t\tif (domain != null) {\n\t\t\tlines.push(`🔗 {url}`)\n\t\t\tlet cf = checkCloudflare(domain)\n\t\t\tlines.push(`Cloudflare DNS: {cf}`)\n\t\t\tlet pt = checkPhishtank(url)\n\t\t\tlines.push(`PhishTank: {pt}`)\n\t\t\tif (Plugin:config.checkSucuri) {\n\t\t\t\tlines.push(`Sucuri: {checkSucuri(domain)}`)\n\t\t\t}\n\t\t\tif (cf.index_of(\"🚫\") != -1 || pt.index_of(\"🚨\") != -1) {\n\t\t\t\tif (flaggedDomain == null) flaggedDomain = domain\n\t\t\t}\n\t\t\tlines.push(\"\")\n\t\t}\n\t}\n\tif (urls.len > targets.len) {\n\t\tlines.push(`(URL {urls.len}件のうち先頭{targets.len}件をチェックしました)`)\n\t}\n\n\tlet dtype = if (flaggedDomain != null) \"warning\" else \"info\"\n\tMk:dialog(\"URL 安全チェック結果\", lines.join(Str:lf), dtype)\n\n\tif (flaggedDomain != null) {\n\t\tif (Mk:confirm(\"URL 安全チェック\", `危険判定が出ました。urlscan.io で {flaggedDomain} を詳しく調べますか?`)) {\n\t\t\tPlugin:open_url(`https://urlscan.io/domain/{flaggedDomain}`)\n\t\t}\n\t}\n})\n"}
+{"type":"plugin","data":"/// @ 1.2.1\n\n// URL 安全チェック — ノート内の URL をキー不要の脅威 DB で照合\n//\n// ノートメニューから本文中の URL を抽出し、以下の 3 ソースで照合する。\n// いずれも API キー不要 (Vault 不要、vault.fetch の plugin 開放 #759 に依存しない):\n// - Cloudflare Malware DNS (security.cloudflare-dns.com):\n// 脅威リスト掲載ドメインは 0.0.0.0 に censored されるのを判定に使う\n// - PhishTank (checkurl.phishtank.com): フィッシング URL データベース照合\n// - Sucuri SiteCheck (sitecheck.sucuri.net): ブラックリスト照合 + 総合評価\n//\n// Nd:http は呼び出しごとに確認ダイアログが出る (network.external)。\n// 「今後この操作を確認しない」で以降はスキップされる。\n// AiScript に try/catch は無いため、いずれかのサービスがネットワーク的に\n// 落ちている場合はハンドラごと停止する (HTTP エラーステータスは処理する)。\n\n### {\n\tname: \"URL 安全チェック\"\n\tversion: \"1.0.1\"\n\tauthor: \"@hitalin\"\n\tdescription: \"ノート本文の URL を Cloudflare Malware DNS / PhishTank / Sucuri SiteCheck で照合し、フィッシング・マルウェアの兆候を確認します。チェック時に URL が各サービスに送信されます。API キーは不要です\"\n\tpermissions: [\"network.external\"]\n\tconfig: {\n\t\tcheckSucuri: {\n\t\t\ttype: \"boolean\"\n\t\t\tlabel: \"Sucuri SiteCheck も実行する\"\n\t\t\tdescription: \"ブラックリスト照合と総合評価。未スキャンのドメインは応答に時間がかかることがあります\"\n\t\t\tdefault: true\n\t\t}\n\t\tmaxUrls: {\n\t\t\ttype: \"number\"\n\t\t\tlabel: \"1回にチェックする URL の上限\"\n\t\t\tdefault: 3\n\t\t}\n\t}\n}\n\nlet trailingClosers = [\")\", \")\", \"」\", \"]\", \",\", \"、\", \"。\", \"\\\"\", \">\"]\n\n// 文末で URL に括弧や句読点が続くケース (「(https://...)」等) の末尾記号を剥がす\n/// Nd:http の失敗理由。status 0 は通信断 (DNS 失敗・接続拒否・タイムアウト) で、\n/// NoteDeck は throw せず値で返す (notedeck#1294)\n@httpLabel(st) { if (st == 0) { \"通信できません\" } else { `HTTP {st}` } }\n\n@stripTail(token) {\n\tvar body = token\n\tloop {\n\t\tif (body.len == 0) break\n\t\tlet last = body.slice(body.len - 1, body.len)\n\t\tif (!trailingClosers.incl(last)) break\n\t\tbody = body.slice(0, body.len - 1)\n\t}\n\tbody\n}\n\n@extractUrls(text) {\n\tlet normalized = text.replace(\" \", Str:lf).replace(\" \", Str:lf)\n\tvar urls = []\n\teach (let token, normalized.split(Str:lf)) {\n\t\tvar hIdx = token.index_of(\"https://\")\n\t\tif (hIdx == -1) hIdx = token.index_of(\"http://\")\n\t\tif (hIdx != -1) {\n\t\t\tlet url = stripTail(token.slice(hIdx, token.len))\n\t\t\tif (url.index_of(\"://\") != -1 && !urls.incl(url)) {\n\t\t\t\turls.push(url)\n\t\t\t}\n\t\t}\n\t}\n\turls\n}\n\n@extractDomain(url) {\n\tlet idx = url.index_of(\"://\")\n\tif (idx == -1) return null\n\tvar rest = url.slice(idx + 3, url.len)\n\teach (let sep, [\"/\", \"?\", \"#\"]) {\n\t\tlet i = rest.index_of(sep)\n\t\tif (i != -1) rest = rest.slice(0, i)\n\t}\n\tlet atIdx = rest.index_of(\"@\")\n\tif (atIdx != -1) rest = rest.slice(atIdx + 1, rest.len)\n\tlet colonIdx = rest.index_of(\":\")\n\tif (colonIdx != -1) rest = rest.slice(0, colonIdx)\n\tif (rest == \"\") return null\n\trest\n}\n\n// Cloudflare Malware DNS (1.1.1.2 相当の DoH)。脅威リスト掲載ドメインは\n// 0.0.0.0 への censored 応答になる\n@checkCloudflare(domain) {\n\tlet res = Nd:http(`https://security.cloudflare-dns.com/dns-query?name={Uri:encode_component(domain)}&type=A`, {\n\t\tmethod: \"GET\"\n\t\theaders: { \"accept\": \"application/dns-json\" }\n\t})\n\tif (res.status != 200) return `❓ 取得失敗 ({httpLabel(res.status)})`\n\tlet data = Json:parse(res.body)\n\tvar blocked = false\n\tif (data.Answer != null) {\n\t\teach (let a, data.Answer) {\n\t\t\tif (a.data == \"0.0.0.0\") blocked = true\n\t\t}\n\t}\n\tif (blocked) return \"🚫 危険 — 脅威リストでブロックされています\"\n\tif (data.Status == 3) return \"❓ ドメインが存在しません\"\n\t\"✅ 検出なし\"\n}\n\n@checkPhishtank(url) {\n\t// PhishTank は phishtank/ prefix の User-Agent を要求する (それ以外は 403)\n\tlet res = Nd:http(\"https://checkurl.phishtank.com/checkurl/\", {\n\t\tmethod: \"POST\"\n\t\theaders: {\n\t\t\t\"content-type\": \"application/x-www-form-urlencoded\"\n\t\t\t\"user-agent\": \"phishtank/notedeck\"\n\t\t}\n\t\tbody: `url={Uri:encode_component(url)}&format=json`\n\t})\n\tif (res.status != 200) return `❓ 取得失敗 ({httpLabel(res.status)})`\n\tlet data = Json:parse(res.body)\n\tif (data.results == null) return \"❓ 応答を解釈できませんでした\"\n\tif (data.results.in_database == true && data.results.valid == true) {\n\t\treturn \"🚨 危険 — フィッシングサイトとして登録済み\"\n\t}\n\t\"✅ 検出なし\"\n}\n\n@checkSucuri(domain) {\n\tlet res = Nd:http(`https://sitecheck.sucuri.net/api/v3/?scan={Uri:encode_component(domain)}`, {\n\t\tmethod: \"GET\"\n\t\ttimeoutMs: 60000\n\t})\n\tif (res.status != 200) return `❓ 取得失敗 ({httpLabel(res.status)})`\n\tlet data = Json:parse(res.body)\n\tvar parts = []\n\tif (data.ratings != null && data.ratings.total != null) {\n\t\tparts.push(`総合評価 {data.ratings.total.rating}`)\n\t}\n\tif (data.warnings != null) {\n\t\teach (let kv, Obj:kvs(data.warnings)) {\n\t\t\tlet key = kv[0]\n\t\t\tlet n = if (Core:type(kv[1]) == \"arr\") kv[1].len else 1\n\t\t\tif (key == \"blacklist\") {\n\t\t\t\tparts.push(`🚨 ブラックリスト掲載 {n}件`)\n\t\t\t} elif (key == \"scan_failed\") {\n\t\t\t\tparts.push(\"スキャン未完了\")\n\t\t\t} else {\n\t\t\t\tparts.push(`⚠️ 警告 ({key}) {n}件`)\n\t\t\t}\n\t\t}\n\t}\n\tif (parts.len == 0) return \"✅ 警告なし\"\n\tparts.join(\" / \")\n}\n\nPlugin:register_note_action(\"URL の安全性をチェック\", @(note) {\n\tif (note.text == null) {\n\t\tMk:dialog(\"URL 安全チェック\", \"テキストのないノートです\", \"info\")\n\t\treturn null\n\t}\n\tlet urls = extractUrls(note.text)\n\tif (urls.len == 0) {\n\t\tMk:dialog(\"URL 安全チェック\", \"ノートに URL が見つかりませんでした\", \"info\")\n\t\treturn null\n\t}\n\tvar targets = []\n\teach (let url, urls) {\n\t\tif (targets.len < Plugin:config.maxUrls) targets.push(url)\n\t}\n\n\tvar lines = []\n\tvar flaggedDomain = null\n\teach (let url, targets) {\n\t\tlet domain = extractDomain(url)\n\t\tif (domain != null) {\n\t\t\tlines.push(`🔗 {url}`)\n\t\t\tlet cf = checkCloudflare(domain)\n\t\t\tlines.push(`Cloudflare DNS: {cf}`)\n\t\t\tlet pt = checkPhishtank(url)\n\t\t\tlines.push(`PhishTank: {pt}`)\n\t\t\tif (Plugin:config.checkSucuri) {\n\t\t\t\tlines.push(`Sucuri: {checkSucuri(domain)}`)\n\t\t\t}\n\t\t\tif (cf.index_of(\"🚫\") != -1 || pt.index_of(\"🚨\") != -1) {\n\t\t\t\tif (flaggedDomain == null) flaggedDomain = domain\n\t\t\t}\n\t\t\tlines.push(\"\")\n\t\t}\n\t}\n\tif (urls.len > targets.len) {\n\t\tlines.push(`(URL {urls.len}件のうち先頭{targets.len}件をチェックしました)`)\n\t}\n\n\tlet dtype = if (flaggedDomain != null) \"warning\" else \"info\"\n\tMk:dialog(\"URL 安全チェック結果\", lines.join(Str:lf), dtype)\n\n\tif (flaggedDomain != null) {\n\t\tif (Mk:confirm(\"URL 安全チェック\", `危険判定が出ました。urlscan.io で {flaggedDomain} を詳しく調べますか?`)) {\n\t\t\tPlugin:open_url(`https://urlscan.io/domain/{flaggedDomain}`)\n\t\t}\n\t}\n})\n"}
diff --git a/public/registry/plugins/url-safety-check/plugin.is b/public/registry/plugins/url-safety-check/plugin.is
index 9a08c9c..28c5fab 100644
--- a/public/registry/plugins/url-safety-check/plugin.is
+++ b/public/registry/plugins/url-safety-check/plugin.is
@@ -38,6 +38,10 @@
let trailingClosers = [")", ")", "」", "]", ",", "、", "。", "\"", ">"]
// 文末で URL に括弧や句読点が続くケース (「(https://...)」等) の末尾記号を剥がす
+/// Nd:http の失敗理由。status 0 は通信断 (DNS 失敗・接続拒否・タイムアウト) で、
+/// NoteDeck は throw せず値で返す (notedeck#1294)
+@httpLabel(st) { if (st == 0) { "通信できません" } else { `HTTP {st}` } }
+
@stripTail(token) {
var body = token
loop {
@@ -88,7 +92,7 @@ let trailingClosers = [")", ")", "」", "]", ",", "、", "。", "\"", ">"]
method: "GET"
headers: { "accept": "application/dns-json" }
})
- if (res.status != 200) return `❓ 取得失敗 (HTTP {res.status})`
+ if (res.status != 200) return `❓ 取得失敗 ({httpLabel(res.status)})`
let data = Json:parse(res.body)
var blocked = false
if (data.Answer != null) {
@@ -111,7 +115,7 @@ let trailingClosers = [")", ")", "」", "]", ",", "、", "。", "\"", ">"]
}
body: `url={Uri:encode_component(url)}&format=json`
})
- if (res.status != 200) return `❓ 取得失敗 (HTTP {res.status})`
+ if (res.status != 200) return `❓ 取得失敗 ({httpLabel(res.status)})`
let data = Json:parse(res.body)
if (data.results == null) return "❓ 応答を解釈できませんでした"
if (data.results.in_database == true && data.results.valid == true) {
@@ -125,7 +129,7 @@ let trailingClosers = [")", ")", "」", "]", ",", "、", "。", "\"", ">"]
method: "GET"
timeoutMs: 60000
})
- if (res.status != 200) return `❓ 取得失敗 (HTTP {res.status})`
+ if (res.status != 200) return `❓ 取得失敗 ({httpLabel(res.status)})`
let data = Json:parse(res.body)
var parts = []
if (data.ratings != null && data.ratings.total != null) {
diff --git a/public/registry/queries.json b/public/registry/queries.json
index 5de59f3..8d543ac 100644
--- a/public/registry/queries.json
+++ b/public/registry/queries.json
@@ -1,6 +1,6 @@
{
"version": 1,
- "updatedAt": "2026-10-28T23:08:30.161Z",
+ "updatedAt": "2026-10-29T19:40:04.494Z",
"queries": [
{
"id": "bot-only",
diff --git a/public/registry/skills.json b/public/registry/skills.json
index e2f7f94..6913fe9 100644
--- a/public/registry/skills.json
+++ b/public/registry/skills.json
@@ -1,6 +1,6 @@
{
"version": 1,
- "updatedAt": "2026-10-28T23:08:30.161Z",
+ "updatedAt": "2026-10-29T19:40:04.494Z",
"skills": [
{
"id": "aiscript-surgeon",
diff --git a/public/registry/themes.json b/public/registry/themes.json
index eff471d..66eaf7a 100644
--- a/public/registry/themes.json
+++ b/public/registry/themes.json
@@ -1,6 +1,6 @@
{
"version": 1,
- "updatedAt": "2026-10-28T23:08:30.161Z",
+ "updatedAt": "2026-10-29T19:40:04.494Z",
"themes": [
{
"id": "adwaita-dark",
diff --git a/public/registry/widgets.json b/public/registry/widgets.json
index 00cb9b6..4778442 100644
--- a/public/registry/widgets.json
+++ b/public/registry/widgets.json
@@ -1,6 +1,6 @@
{
"version": 1,
- "updatedAt": "2026-10-28T23:08:30.161Z",
+ "updatedAt": "2026-10-29T19:40:04.494Z",
"widgets": [
{
"id": "aiscript-health",
@@ -78,7 +78,7 @@
],
"sourceUrl": "https://store.notedeck.io/registry/widgets/atcoder/widget.is",
"apiUrl": "https://store.notedeck.io/registry/widgets/atcoder/api.json",
- "sha512": "9207ff50608091967ad1d3b948786b48b8520a95b8dcd111f0e87ee48cc5be90f850ce51302cc1ae8451e0b4d53f2d5ca696a4c1426d5825250fc353edabd55d",
+ "sha512": "5e61fedc9b2670e8f080692244fb7347948bac5f51fa59f9bcf9a871d1e1469c36ff5f6cbfca6b326582a6a5579edb51448d93e422a23ad8625e275219c630bf",
"createdAt": "2026-07-01T08:50:22+09:00",
"updatedAt": "2026-09-25T18:20:04+09:00",
"authorUrl": "https://github.com/hitalin",
@@ -278,7 +278,7 @@
],
"sourceUrl": "https://store.notedeck.io/registry/widgets/ctftime/widget.is",
"apiUrl": "https://store.notedeck.io/registry/widgets/ctftime/api.json",
- "sha512": "2270a6bb535b1a5fe500bc5661037a8202a42311dc13d1f37ecd233f74c63310eaa494fb40a54ef60ca83f1b271cbd2e0ff517da8656e82e70ffe5b4ba9a8708",
+ "sha512": "bea143bec262967466a900d4938eb17188a11d6cd6a0d57d5584cbd6cc91c10b4d12709edf85f921848101180cbe8742afe17f0b47cd895a8174aa13cd374c05",
"createdAt": "2026-07-01T09:04:06+09:00",
"updatedAt": "2026-09-25T18:20:04+09:00",
"authorUrl": "https://github.com/hitalin",
@@ -325,7 +325,7 @@
],
"sourceUrl": "https://store.notedeck.io/registry/widgets/cve/widget.is",
"apiUrl": "https://store.notedeck.io/registry/widgets/cve/api.json",
- "sha512": "5df6816c790214e7b8df203c8efb6be48bc8bfb25e98a4c03c7f927f84577a7ccd24b0574423cab3b92360ed53a3c2d4aa4bf990b2996f9f07cd4b0a38c8a2f3",
+ "sha512": "64653b7063f6399c47bdbc958fcdeb62a123b2299c276652ed701739b89df58340464eb621258e974713cc3981f8baf4492a6542d89f070f080a802f738d2dac",
"createdAt": "2026-06-25T14:23:09+09:00",
"updatedAt": "2026-09-25T18:20:04+09:00",
"authorUrl": "https://github.com/hitalin",
@@ -373,7 +373,7 @@
],
"sourceUrl": "https://store.notedeck.io/registry/widgets/discord-online/widget.is",
"apiUrl": "https://store.notedeck.io/registry/widgets/discord-online/api.json",
- "sha512": "4e29cbe7f511b2d909531b48c977d3c1a80a408ecb095bbbb91269e2dd68c347226e267642f2fae7611f235ea3d308a52c02422ed08181633e17c91d07041bd7",
+ "sha512": "1a54f8e3874e961a8cf5d4f5a5bc70fa13ada77680c7efe7755d82d5c5601064ab5367d1555ec2a5e83dd3cbb117b50e177dea79d3cd2a8c5cd1c779d09fa655",
"createdAt": "2026-06-30T19:04:56+09:00",
"updatedAt": "2026-09-25T18:20:04+09:00",
"authorUrl": "https://github.com/hitalin",
@@ -422,7 +422,7 @@
],
"sourceUrl": "https://store.notedeck.io/registry/widgets/discord-presence/widget.is",
"apiUrl": "https://store.notedeck.io/registry/widgets/discord-presence/api.json",
- "sha512": "2b682319b8751ed771e26cc7c441b65182dad51530751e32cf5374ae8d8e18617bd461301c607f32f4db48621006e33dc1da722ca8124f87535a21916f1de4e6",
+ "sha512": "584fd06986b023c3075dd8fba0d6cc71739971fe230e6ce615f6eec919935e583fbe8211e9bea278a6c713d6db437ebc32e5729bdb6f439abc98d390ab03b576",
"createdAt": "2026-10-29T04:15:03+09:00",
"updatedAt": "2026-10-29T04:25:33+09:00",
"authorUrl": "https://github.com/hitalin",
@@ -568,7 +568,7 @@
],
"sourceUrl": "https://store.notedeck.io/registry/widgets/listenbrainz/widget.is",
"apiUrl": "https://store.notedeck.io/registry/widgets/listenbrainz/api.json",
- "sha512": "c31e78ee68ca0ec0e321aaef250aca242c946dfed30acd4e20757c919188fc966da29206072d085e5477635cb1bb7129f58b5b52b974212adead368598436f92",
+ "sha512": "771079ee3b41ecfbf66241985c52f89fd82a6d51d405f9b0267732c43674f3bf233a3a2b334fd5cec6165396b983852c678b3b54ea4114123010a3d8791407a9",
"createdAt": "2026-05-14T15:16:09+09:00",
"updatedAt": "2026-09-25T18:20:04+09:00",
"authorUrl": "https://github.com/hitalin",
@@ -665,7 +665,7 @@
],
"sourceUrl": "https://store.notedeck.io/registry/widgets/minecraft/widget.is",
"apiUrl": "https://store.notedeck.io/registry/widgets/minecraft/api.json",
- "sha512": "d3694777c7c0bcc9cf26cd5a6a6d3146e3b646d8ef9c12962b80376f2c1ce01f631e45c072bfee9a777e0683140a55d58b778bc87dcd67b005096a3f1434146f",
+ "sha512": "e83e672e36ce3cf4b1de3e6a0d436d90311794739f9ca28826cab73ed77440f8f1539b360828d36e665aeed5014c7f790a4561f98aa0d0b542ed577630a113ef",
"createdAt": "2026-06-30T18:46:47+09:00",
"updatedAt": "2026-09-25T18:20:04+09:00",
"authorUrl": "https://github.com/hitalin",
@@ -713,7 +713,7 @@
],
"sourceUrl": "https://store.notedeck.io/registry/widgets/misskey-online/widget.is",
"apiUrl": "https://store.notedeck.io/registry/widgets/misskey-online/api.json",
- "sha512": "042fd3b1d512d1ba1ad9d6cbe547f0bd99a4ba5618cc2552b4783f34cf16d69703c2cc806925d2928a81e00374c393de6981d4512e46a5ef9b92a663c9ae47c5",
+ "sha512": "429a7a8c5db21a0aa1a089127bf0ac175f027dc04554cd259126a2df9371fa0fc10facc862a1add35683da20d58f1e4f6f2544ae2d763a99fb2b8720ce705e80",
"createdAt": "2026-06-30T19:07:19+09:00",
"updatedAt": "2026-09-25T18:20:04+09:00",
"authorUrl": "https://github.com/hitalin",
@@ -762,7 +762,7 @@
],
"sourceUrl": "https://store.notedeck.io/registry/widgets/nodeinfo/widget.is",
"apiUrl": "https://store.notedeck.io/registry/widgets/nodeinfo/api.json",
- "sha512": "107ca7daa16937e18fab8abf7838ad83885fbb19046b2b0150260fb68799e1230ea93d89b41e64fa0dab815cd1eaca0e92099765df9cedbfd35830a6470738f3",
+ "sha512": "c8a34a3667987b74cd5f805897fb48c61f7ed32837c911bfd84e251651e215918504e5d0034cc312764c4f4db39867669e58cd80c298c3c874b4962b55729f73",
"createdAt": "2026-08-15T03:32:33+09:00",
"updatedAt": "2026-09-25T18:20:04+09:00",
"authorUrl": "https://github.com/hitalin",
@@ -811,7 +811,7 @@
],
"sourceUrl": "https://store.notedeck.io/registry/widgets/okj-prices/widget.is",
"apiUrl": "https://store.notedeck.io/registry/widgets/okj-prices/api.json",
- "sha512": "2d249cbb3ff3610f8f53e735971e418768d0a9aa88e226abb9cd77b6ebe0135b0a1763e53575dd26dfc691c99710b136684881fa502880f553e2d39a0e3e0a65",
+ "sha512": "8ecc733d17084b9118fda2087ed7bc5756fac175ae5b4ba471520ad00b511c2825a382ad66bc24754150595431e40cceedeaeb9f5dfb37ffe1e52a3f8e0d7af4",
"createdAt": "2026-07-20T08:37:27+09:00",
"updatedAt": "2026-09-25T18:20:04+09:00",
"authorUrl": "https://github.com/hitalin",
@@ -911,7 +911,7 @@
],
"sourceUrl": "https://store.notedeck.io/registry/widgets/osv-vulns/widget.is",
"apiUrl": "https://store.notedeck.io/registry/widgets/osv-vulns/api.json",
- "sha512": "3144f60a44effc996ad98616e5af4360cb18fc9acf2288ff6c81ad7ea54d3fdbbb9da1c0bffb1bd67d64c4452697d4996a1a30d2852364afb80f7e7d4c00a39b",
+ "sha512": "eb449f4ba3db1394758e1e1044e36c9c733700ab74875728fa6dad9213d752dcbcd46839e3faaf217565a08254e43c9c9faa7b7142a92b85ef56ddb2a74543df",
"createdAt": "2026-08-15T03:33:09+09:00",
"updatedAt": "2026-09-25T18:20:04+09:00",
"authorUrl": "https://github.com/hitalin",
@@ -1059,7 +1059,7 @@
],
"sourceUrl": "https://store.notedeck.io/registry/widgets/soundcloud-feed/widget.is",
"apiUrl": "https://store.notedeck.io/registry/widgets/soundcloud-feed/api.json",
- "sha512": "94282649dc7df74e925948c825d05513497f813581aec04b0a0e0d62e74a135f2f45302ba7d3a6b1c13343e94ac40c48f2a303a1686e7b717c81d6288ca4e7a3",
+ "sha512": "cb8d4df7b017fdd6eed369171d906a84c5556727654bde796db8376b0a3a2e9d7c1a638490d288e41afe113f3cc9db7e789cad839ae254aca4fcd654641e444d",
"createdAt": "2026-10-28T21:24:46+09:00",
"updatedAt": "2026-10-28T21:24:46+09:00",
"authorUrl": "https://github.com/hitalin",
@@ -1302,7 +1302,7 @@
],
"sourceUrl": "https://store.notedeck.io/registry/widgets/zenn/widget.is",
"apiUrl": "https://store.notedeck.io/registry/widgets/zenn/api.json",
- "sha512": "45de70ec5b7736db382b58e29c65077251ebf49cb2cf7cc919a7f06721f7e1319cc36b1f539c027fcf739134279165c71d5a9c987801de5cf55d3fec328c0355",
+ "sha512": "e5b48dfb90905efa539ed20ddd45aa7191064092c44c402742300cfabefb783d60c601fd09162ee9065b9d78f041916aa82259663341b776bfc1e0fc4a52aec5",
"createdAt": "2026-07-01T08:40:14+09:00",
"updatedAt": "2026-09-25T18:20:04+09:00",
"authorUrl": "https://github.com/hitalin",
@@ -1995,7 +1995,7 @@
],
"sourceUrl": "https://store.notedeck.io/registry/widgets/nicolive-watch/widget.is",
"apiUrl": "https://store.notedeck.io/registry/widgets/nicolive-watch/api.json",
- "sha512": "9cfb605e7ced21a24fe1762c00f5017dd774177a5c6559b16da7a32cbc9319fd220b0c30197ecf6107ab39f286694ae56e1542c85db4549563bbb8aac8364b83",
+ "sha512": "4eb2b4c799a159db05a08a8f410d19d677f51c214262ab75e01971744e97d789e4c37106018bf937e97d2f72d949881d075718b50bfd520819a8ea68a67a491a",
"createdAt": "2026-10-28T21:24:46+09:00",
"updatedAt": "2026-10-28T21:24:46+09:00",
"authorUrl": "https://github.com/hitalin",
@@ -2149,7 +2149,7 @@
],
"sourceUrl": "https://store.notedeck.io/registry/widgets/vocaloid-trending/widget.is",
"apiUrl": "https://store.notedeck.io/registry/widgets/vocaloid-trending/api.json",
- "sha512": "ec8794bc1bff2cc45b3f9b67a49a83a0b83f50e29f858bde1c7e947cbadde2a4c63d67c069b9aec999750b4b5079c1f07c9865630c4e7098b0fd5881c2bc6a6d",
+ "sha512": "54ff05ac29d70c56b54342dbfdd915e9ed31093460dfc3ce7e11beb1d31b2b6e9e3500ba8425d6961061e2e5b784db2077b2a928903bfeefa1425eaf8adcf4c5",
"createdAt": "2026-10-28T21:24:46+09:00",
"updatedAt": "2026-10-28T21:24:46+09:00",
"authorUrl": "https://github.com/hitalin",
@@ -2628,7 +2628,7 @@
],
"sourceUrl": "https://store.notedeck.io/registry/widgets/github-releases/widget.is",
"apiUrl": "https://store.notedeck.io/registry/widgets/github-releases/api.json",
- "sha512": "894f093f016a76a60f31c1ee31dc298bdbc0e4ccc58ba998fc23fa192eb5b791c3e5058235e15fdb4b05651ad12ae2ef9aadfffb9e2a810274c6ae3341b8cd98",
+ "sha512": "195685a3cba897dc4ad1ef1e8a25303b6011365b702e4f71a54646819be866c4177480ec75d462f24f366bd277c6b61e73c8cc43989803cc669a158e18dbd796",
"createdAt": "2026-06-19T16:01:13+09:00",
"updatedAt": "2026-09-25T18:20:04+09:00",
"authorUrl": "https://github.com/hitalin",
@@ -2820,7 +2820,7 @@
],
"sourceUrl": "https://store.notedeck.io/registry/widgets/earthquake/widget.is",
"apiUrl": "https://store.notedeck.io/registry/widgets/earthquake/api.json",
- "sha512": "255a646cd0ed31db9b813bb2fc28f291c27f200b5f76f448a1b5434579f5249ef32c75bd29fd2917e4bccc96f9f83ee43a52fd7785b17200bbe5557bb23050df",
+ "sha512": "26d80040f0f2ff2e63c36c5f34c6e257881173d1fbefb826a8536e4dd33daa54c2a4b9db37cd75ddb6e151c69ffe8c1a436c1da73864a751a18f8b9b75b52e95",
"createdAt": "2026-06-18T15:05:26+09:00",
"updatedAt": "2026-09-25T18:20:04+09:00",
"authorUrl": "https://github.com/hitalin",
@@ -2866,7 +2866,7 @@
],
"sourceUrl": "https://store.notedeck.io/registry/widgets/weather/widget.is",
"apiUrl": "https://store.notedeck.io/registry/widgets/weather/api.json",
- "sha512": "25173ea09afa1e59162c8f394144f526c6af6a4551eea60c660bc1e788ac85c2c173d6553e79abc20b7b05947a3f92b887e473f2c82342453c9807b82e34ddf8",
+ "sha512": "220fcb9abfc7c867c4587b7406d854c367767aaf4b5709ed08ee92a5a5808ab081277a416df48868add508e92e40a19465be34e2d2c1cacb1f22a006dd028466",
"createdAt": "2026-06-18T14:16:28+09:00",
"updatedAt": "2026-09-25T18:20:04+09:00",
"authorUrl": "https://github.com/hitalin",
@@ -3072,7 +3072,7 @@
],
"sourceUrl": "https://store.notedeck.io/registry/widgets/ink-new-servers/widget.is",
"apiUrl": "https://store.notedeck.io/registry/widgets/ink-new-servers/api.json",
- "sha512": "bf651bfbd1ed14f63c288a2860dd1261ae807998e51467c4e5c150fe8f3e80d2c28c12d70583b7c899148ae9e3eb245dcde45c8709369a1eb303192bcf3a2ba2",
+ "sha512": "e9b08b989ba0512086627881bc509052c39bad98c9f851aceb4ad15937e292c3e493e7c9a71936eb4788ca158047fe5ebb7b21197e422ecc0f72bba341473e1f",
"createdAt": "2026-07-07T17:34:41+09:00",
"updatedAt": "2026-09-25T18:20:04+09:00",
"authorUrl": "https://github.com/hitalin",
@@ -3119,7 +3119,7 @@
],
"sourceUrl": "https://store.notedeck.io/registry/widgets/crypto/widget.is",
"apiUrl": "https://store.notedeck.io/registry/widgets/crypto/api.json",
- "sha512": "4e0932dffb416be9163b367f8e38b0f1b0334c3bc3a9d9b3ca41c0d375d9da72f48227b69727da1d552180abdc199fd8cfa5d43ff4fd0e95e872b5de727999fa",
+ "sha512": "0a3363e7dedcdf0ec2b2fa8409e6b73a628280021714fb360555b2484f7143afa67c5e025ed133530e7f2198dd0f1be9406214c73afbc0043add4581d0eacdd7",
"createdAt": "2026-06-23T12:10:19+09:00",
"updatedAt": "2026-09-25T18:20:04+09:00",
"authorUrl": "https://github.com/hitalin",
@@ -3167,7 +3167,7 @@
],
"sourceUrl": "https://store.notedeck.io/registry/widgets/stocks/widget.is",
"apiUrl": "https://store.notedeck.io/registry/widgets/stocks/api.json",
- "sha512": "f8ddbd0d280e60b8f3809a1b41ff94a456595918329242e8c9c98aeb4052f5899fae83c5a69c5e399e7d67898f520a6734234edad62e0905438d2b186ada324b",
+ "sha512": "763a6d0f31d58491d9966c161b5b763d60cb710884d4d5e4852f47a5d76c2dfe7847489b274a353004635cf6f4cee476b8d04633ed90b9e21e6b9f19d0da2035",
"createdAt": "2026-06-23T13:02:37+09:00",
"updatedAt": "2026-09-25T18:20:04+09:00",
"authorUrl": "https://github.com/hitalin",
@@ -3426,7 +3426,7 @@
],
"sourceUrl": "https://store.notedeck.io/registry/widgets/joinmisskey-stats/widget.is",
"apiUrl": "https://store.notedeck.io/registry/widgets/joinmisskey-stats/api.json",
- "sha512": "c73d7c058cb63159d3ae4b2913168324a3c4dccb1a1b0dfed5ecd896637428f2f36b6c54de42a8c4ad7ecd999d797e69c79d387713df5a1d12be3ff30c01d020",
+ "sha512": "0b39151ebfcee2fe8233ec057250d753f35bf6c3164fe36182e5e4a402aa91a19b8021fb72a3f7431c3b325e96d9a2ad70935c905ace276374301822edf90812",
"createdAt": "2026-07-07T17:13:35+09:00",
"updatedAt": "2026-09-25T18:20:04+09:00",
"authorUrl": "https://github.com/hitalin",
diff --git a/public/registry/widgets/atcoder/api.json b/public/registry/widgets/atcoder/api.json
index 833b8c1..b65ee5d 100644
--- a/public/registry/widgets/atcoder/api.json
+++ b/public/registry/widgets/atcoder/api.json
@@ -1 +1 @@
-{"type":"widget","data":"/// @ 1.2.1\n/// AtCoder ウィジェット\n/// AtCoder 公式の history/json から任意ユーザーのレーティングと\n/// 直近コンテスト結果をレート色付きで表示。API キー不要。\n/// デフォルトは chokudai (AtCoder 社長)。設定でユーザー名に変更できる。\n/// 設計の元ネタ: zenn / github-releases ウィジェット (表示・更新ループ + 設定 UI)。\n\n// 履歴に表示するコンテスト件数。ここを書き換えると増減できる。\nlet COUNT = 5\n\n// アクセント・テキスト色\nlet MUTED = \"8a96a3\"\nlet LINE = \"5a6573\"\nlet UP = \"43a047\"\nlet DOWN = \"f43f5e\"\n\n@dflt(v, d) {\n if (Core:type(v) == \"null\") d else v\n}\n\n@get(o, k, d) {\n if (Core:type(o) != \"obj\") {\n d\n } elif (Obj:has(o, k)) {\n dflt(o[k], d)\n } else {\n d\n }\n}\n\n// 設定: 対象ユーザー名。Mk:save で永続化される。\nvar username = dflt(Mk:load(\"username\"), \"chokudai\")\nvar firstRun = true\n\n/// ISO 8601 (\"2024-08-19T00:00:00+09:00\") を \"2024/08/19\" にする\n@dateLabel(s) {\n if (Core:type(s) == \"str\" && s.len >= 10) s.slice(0, 10).replace(\"-\", \"/\") else \"-\"\n}\n\n/// AtCoder 標準のレート色 (灰/茶/緑/水/青/黄/橙/赤)\n@ratingColor(r) {\n if (Core:type(r) != \"num\") MUTED\n elif (r < 400) \"808080\"\n elif (r < 800) \"804000\"\n elif (r < 1200) \"008000\"\n elif (r < 1600) \"00b8b8\"\n elif (r < 2000) \"3b6cff\"\n elif (r < 2400) \"c0c000\"\n elif (r < 2800) \"ff8000\"\n else \"ff0000\"\n}\n\n/// レート帯の名前 (色の通称)\n@ratingName(r) {\n if (Core:type(r) != \"num\") \"—\"\n elif (r < 400) \"灰\"\n elif (r < 800) \"茶\"\n elif (r < 1200) \"緑\"\n elif (r < 1600) \"水\"\n elif (r < 2000) \"青\"\n elif (r < 2400) \"黄\"\n elif (r < 2800) \"橙\"\n else \"赤\"\n}\n\n/// レート変動を符号付きで色分け\n@deltaLabel(oldR, newR) {\n let d = newR - oldR\n if (d > 0) `$[fg.color={UP} ▲{d}]`\n elif (d < 0) `$[fg.color={DOWN} ▼{0 - d}]`\n else `$[fg.color={MUTED} ±0]`\n}\n\n/// ContestScreenName (\"agc067.contest.atcoder.jp\") からコンテスト URL を作る\n@contestUrl(screenName) {\n if (Core:type(screenName) == \"str\" && screenName != \"\") {\n `https://atcoder.jp/contests/{screenName.split(\".\")[0]}`\n } else {\n `https://atcoder.jp/users/{username}`\n }\n}\n\n/// レート色の小さな丸\n@dot(color) {\n `$[fg.color={color} ●]`\n}\n\n/// 横区切り線\n@divider() {\n Ui:C:mfm({ text: `
$[fg.color={LINE} ───────────────]` })\n}\n\n@configFolder() {\n Ui:C:folder({\n title: \"⚙️ 設定\"\n opened: false\n children: [\n Ui:C:textInput({\n onInput: @(v) {\n username = v\n Mk:save(\"username\", v)\n firstRun = true\n render()\n }\n default: username\n label: \"ユーザー名\"\n text: \"AtCoder のユーザー名 (例: chokudai)\"\n })\n ]\n })\n}\n\n@fetchHistory() {\n let url = `https://atcoder.jp/users/{username}/history/json`\n let res = Nd:http(url, {\n method: \"GET\"\n headers: {\n \"User-Agent\": \"notedeck-atcoder-widget\"\n \"Accept\": \"application/json\"\n }\n })\n if (res.status != 200) {\n { error: `取得失敗 (HTTP {res.status})` }\n } else {\n { history: Json:parse(res.body) }\n }\n}\n\n/// rated な参加のみ抽出\n@ratedOnly(history) {\n var picked = []\n for (let i, history.len) {\n let h = history[i]\n if (get(h, \"IsRated\", false)) picked = picked.concat([ h ])\n }\n picked\n}\n\n/// 最高レート\n@maxRating(rated) {\n var m = 0\n for (let i, rated.len) {\n let r = get(rated[i], \"NewRating\", 0)\n if (r > m) m = r\n }\n m\n}\n\n/// 1 件のコンテスト結果を 2 段 (2 要素) のリスト項目にする\n/// MFM は
を展開しないので、行ごとに別要素へ分ける\n@contestRow(h) {\n let cname = get(h, \"ContestName\", \"(コンテスト)\")\n let curl = contestUrl(get(h, \"ContestScreenName\", \"\"))\n let perf = get(h, \"Performance\", 0)\n let place = get(h, \"Place\", \"-\")\n let date = dateLabel(get(h, \"EndTime\", \"\"))\n let rated = get(h, \"IsRated\", false)\n let tail = if (rated) {\n deltaLabel(get(h, \"OldRating\", 0), get(h, \"NewRating\", 0))\n } else {\n `$[fg.color={MUTED} (unrated)]`\n }\n [\n Ui:C:mfm({ text: `{dot(ratingColor(perf))} [{cname}]({curl})` })\n Ui:C:mfm({ text: ` $[fg.color={MUTED} {place}位 ・ perf {perf} ・ {date}] {tail}` })\n ]\n}\n\n@historyView() {\n let data = fetchHistory()\n\n // 取得失敗\n if (Core:type(get(data, \"error\", null)) != \"null\") {\n return [\n Ui:C:mfm({ text: `🏆 $[fg.color={MUTED} @{username}]` })\n Ui:C:mfm({ text: `$[fg.color=f74e52 ⚠️ {data.error}]` })\n configFolder()\n ]\n }\n\n let history = get(data, \"history\", [])\n if (Core:type(history) != \"arr\" || history.len == 0) {\n return [\n Ui:C:mfm({ text: `🏆 $[fg.color={MUTED} @{username}]` })\n Ui:C:mfm({ text: `$[fg.color={MUTED} 📭 コンテスト参加履歴がありません]` })\n Ui:C:mfm({ text: `$[fg.color={MUTED} ユーザー名を確認してください]` })\n configFolder()\n ]\n }\n\n let rated = ratedOnly(history)\n let hasRating = rated.len > 0\n let current = if (hasRating) get(rated[rated.len - 1], \"NewRating\", 0) else 0\n let best = maxRating(rated)\n let curColor = ratingColor(current)\n\n // ヘッダー + 現在レート (主役)\n var body = [\n Ui:C:mfm({ text: `$[fg.color=ff8000 **AtCoder**] [@{username}](https://atcoder.jp/users/{username}) $[fg.color={MUTED} ・{history.len}回]` })\n divider()\n ]\n\n if (hasRating) {\n body = body.concat([\n Ui:C:mfm({ text: `$[fg.color={MUTED} Rating]` })\n Ui:C:mfm({ text: `$[x3 $[fg.color={curColor} **{current}**]]` })\n Ui:C:mfm({ text: `$[fg.color={curColor} ●{ratingName(current)}] $[fg.color={MUTED} 最高] $[fg.color={ratingColor(best)} {best}]` })\n ])\n } else {\n body = body.concat([\n Ui:C:mfm({ text: `$[fg.color={MUTED} レート未確定 (unrated のみ)]` })\n ])\n }\n\n // 直近コンテスト (新しい順)\n body = body.concat([\n divider()\n Ui:C:mfm({ text: `$[fg.color={MUTED} 📊 直近のコンテスト]` })\n ])\n var shown = 0\n var i = history.len - 1\n loop {\n if (i < 0 || shown >= COUNT) break\n body = body.concat(contestRow(history[i]))\n shown = shown + 1\n i = i - 1\n }\n\n // フッター導線 + 設定\n body.concat([\n divider()\n Ui:C:mfm({ text: `[AtCoder で見る →](https://atcoder.jp/users/{username})` })\n configFolder()\n ])\n}\n\n@render() {\n if (firstRun) {\n Ui:render([\n Ui:C:container({\n align: \"center\"\n children: [ Ui:C:text({ text: \"AtCoder の成績を読み込み中… 🏆\" }) ]\n })\n ])\n }\n firstRun = false\n\n let body = historyView()\n Ui:render([\n Ui:C:container({\n children: body\n })\n ])\n}\n\nrender()\n// レート更新はコンテスト後のみなので 10 分間隔で更新\nAsync:interval(600000, render, false)\n"}
+{"type":"widget","data":"/// @ 1.2.1\n/// AtCoder ウィジェット\n/// AtCoder 公式の history/json から任意ユーザーのレーティングと\n/// 直近コンテスト結果をレート色付きで表示。API キー不要。\n/// デフォルトは chokudai (AtCoder 社長)。設定でユーザー名に変更できる。\n/// 設計の元ネタ: zenn / github-releases ウィジェット (表示・更新ループ + 設定 UI)。\n\n// 履歴に表示するコンテスト件数。ここを書き換えると増減できる。\nlet COUNT = 5\n\n// アクセント・テキスト色\nlet MUTED = \"8a96a3\"\nlet LINE = \"5a6573\"\nlet UP = \"43a047\"\nlet DOWN = \"f43f5e\"\n\n@dflt(v, d) {\n if (Core:type(v) == \"null\") d else v\n}\n\n@get(o, k, d) {\n if (Core:type(o) != \"obj\") {\n d\n } elif (Obj:has(o, k)) {\n dflt(o[k], d)\n } else {\n d\n }\n}\n\n// 設定: 対象ユーザー名。Mk:save で永続化される。\nvar username = dflt(Mk:load(\"username\"), \"chokudai\")\nvar firstRun = true\n\n/// ISO 8601 (\"2024-08-19T00:00:00+09:00\") を \"2024/08/19\" にする\n@dateLabel(s) {\n if (Core:type(s) == \"str\" && s.len >= 10) s.slice(0, 10).replace(\"-\", \"/\") else \"-\"\n}\n\n/// AtCoder 標準のレート色 (灰/茶/緑/水/青/黄/橙/赤)\n@ratingColor(r) {\n if (Core:type(r) != \"num\") MUTED\n elif (r < 400) \"808080\"\n elif (r < 800) \"804000\"\n elif (r < 1200) \"008000\"\n elif (r < 1600) \"00b8b8\"\n elif (r < 2000) \"3b6cff\"\n elif (r < 2400) \"c0c000\"\n elif (r < 2800) \"ff8000\"\n else \"ff0000\"\n}\n\n/// レート帯の名前 (色の通称)\n@ratingName(r) {\n if (Core:type(r) != \"num\") \"—\"\n elif (r < 400) \"灰\"\n elif (r < 800) \"茶\"\n elif (r < 1200) \"緑\"\n elif (r < 1600) \"水\"\n elif (r < 2000) \"青\"\n elif (r < 2400) \"黄\"\n elif (r < 2800) \"橙\"\n else \"赤\"\n}\n\n/// レート変動を符号付きで色分け\n@deltaLabel(oldR, newR) {\n let d = newR - oldR\n if (d > 0) `$[fg.color={UP} ▲{d}]`\n elif (d < 0) `$[fg.color={DOWN} ▼{0 - d}]`\n else `$[fg.color={MUTED} ±0]`\n}\n\n/// ContestScreenName (\"agc067.contest.atcoder.jp\") からコンテスト URL を作る\n@contestUrl(screenName) {\n if (Core:type(screenName) == \"str\" && screenName != \"\") {\n `https://atcoder.jp/contests/{screenName.split(\".\")[0]}`\n } else {\n `https://atcoder.jp/users/{username}`\n }\n}\n\n/// レート色の小さな丸\n@dot(color) {\n `$[fg.color={color} ●]`\n}\n\n/// 横区切り線\n@divider() {\n Ui:C:mfm({ text: `$[fg.color={LINE} ───────────────]` })\n}\n\n@configFolder() {\n Ui:C:folder({\n title: \"⚙️ 設定\"\n opened: false\n children: [\n Ui:C:textInput({\n onInput: @(v) {\n username = v\n Mk:save(\"username\", v)\n firstRun = true\n render()\n }\n default: username\n label: \"ユーザー名\"\n text: \"AtCoder のユーザー名 (例: chokudai)\"\n })\n ]\n })\n}\n\n@fetchHistory() {\n let url = `https://atcoder.jp/users/{username}/history/json`\n let res = Nd:http(url, {\n method: \"GET\"\n headers: {\n \"User-Agent\": \"notedeck-atcoder-widget\"\n \"Accept\": \"application/json\"\n }\n })\n if (res.status == 0) {\n // 通信断 (DNS 失敗・接続拒否・タイムアウト)。NoteDeck は throw せず status 0 で返す (notedeck#1294)\n { error: \"通信できません\" }\n } elif (res.status != 200) {\n { error: `取得失敗 (HTTP {res.status})` }\n } else {\n { history: Json:parse(res.body) }\n }\n}\n\n/// rated な参加のみ抽出\n@ratedOnly(history) {\n var picked = []\n for (let i, history.len) {\n let h = history[i]\n if (get(h, \"IsRated\", false)) picked = picked.concat([ h ])\n }\n picked\n}\n\n/// 最高レート\n@maxRating(rated) {\n var m = 0\n for (let i, rated.len) {\n let r = get(rated[i], \"NewRating\", 0)\n if (r > m) m = r\n }\n m\n}\n\n/// 1 件のコンテスト結果を 2 段 (2 要素) のリスト項目にする\n/// MFM は
を展開しないので、行ごとに別要素へ分ける\n@contestRow(h) {\n let cname = get(h, \"ContestName\", \"(コンテスト)\")\n let curl = contestUrl(get(h, \"ContestScreenName\", \"\"))\n let perf = get(h, \"Performance\", 0)\n let place = get(h, \"Place\", \"-\")\n let date = dateLabel(get(h, \"EndTime\", \"\"))\n let rated = get(h, \"IsRated\", false)\n let tail = if (rated) {\n deltaLabel(get(h, \"OldRating\", 0), get(h, \"NewRating\", 0))\n } else {\n `$[fg.color={MUTED} (unrated)]`\n }\n [\n Ui:C:mfm({ text: `{dot(ratingColor(perf))} [{cname}]({curl})` })\n Ui:C:mfm({ text: ` $[fg.color={MUTED} {place}位 ・ perf {perf} ・ {date}] {tail}` })\n ]\n}\n\n@historyView() {\n let data = fetchHistory()\n\n // 取得失敗\n if (Core:type(get(data, \"error\", null)) != \"null\") {\n return [\n Ui:C:mfm({ text: `🏆 $[fg.color={MUTED} @{username}]` })\n Ui:C:mfm({ text: `$[fg.color=f74e52 ⚠️ {data.error}]` })\n configFolder()\n ]\n }\n\n let history = get(data, \"history\", [])\n if (Core:type(history) != \"arr\" || history.len == 0) {\n return [\n Ui:C:mfm({ text: `🏆 $[fg.color={MUTED} @{username}]` })\n Ui:C:mfm({ text: `$[fg.color={MUTED} 📭 コンテスト参加履歴がありません]` })\n Ui:C:mfm({ text: `$[fg.color={MUTED} ユーザー名を確認してください]` })\n configFolder()\n ]\n }\n\n let rated = ratedOnly(history)\n let hasRating = rated.len > 0\n let current = if (hasRating) get(rated[rated.len - 1], \"NewRating\", 0) else 0\n let best = maxRating(rated)\n let curColor = ratingColor(current)\n\n // ヘッダー + 現在レート (主役)\n var body = [\n Ui:C:mfm({ text: `$[fg.color=ff8000 **AtCoder**] [@{username}](https://atcoder.jp/users/{username}) $[fg.color={MUTED} ・{history.len}回]` })\n divider()\n ]\n\n if (hasRating) {\n body = body.concat([\n Ui:C:mfm({ text: `$[fg.color={MUTED} Rating]` })\n Ui:C:mfm({ text: `$[x3 $[fg.color={curColor} **{current}**]]` })\n Ui:C:mfm({ text: `$[fg.color={curColor} ●{ratingName(current)}] $[fg.color={MUTED} 最高] $[fg.color={ratingColor(best)} {best}]` })\n ])\n } else {\n body = body.concat([\n Ui:C:mfm({ text: `$[fg.color={MUTED} レート未確定 (unrated のみ)]` })\n ])\n }\n\n // 直近コンテスト (新しい順)\n body = body.concat([\n divider()\n Ui:C:mfm({ text: `$[fg.color={MUTED} 📊 直近のコンテスト]` })\n ])\n var shown = 0\n var i = history.len - 1\n loop {\n if (i < 0 || shown >= COUNT) break\n body = body.concat(contestRow(history[i]))\n shown = shown + 1\n i = i - 1\n }\n\n // フッター導線 + 設定\n body.concat([\n divider()\n Ui:C:mfm({ text: `[AtCoder で見る →](https://atcoder.jp/users/{username})` })\n configFolder()\n ])\n}\n\n@render() {\n if (firstRun) {\n Ui:render([\n Ui:C:container({\n align: \"center\"\n children: [ Ui:C:text({ text: \"AtCoder の成績を読み込み中… 🏆\" }) ]\n })\n ])\n }\n firstRun = false\n\n let body = historyView()\n Ui:render([\n Ui:C:container({\n children: body\n })\n ])\n}\n\nrender()\n// レート更新はコンテスト後のみなので 10 分間隔で更新\nAsync:interval(600000, render, false)\n"}
diff --git a/public/registry/widgets/atcoder/widget.is b/public/registry/widgets/atcoder/widget.is
index 598c30e..77e1298 100644
--- a/public/registry/widgets/atcoder/widget.is
+++ b/public/registry/widgets/atcoder/widget.is
@@ -119,7 +119,10 @@ var firstRun = true
"Accept": "application/json"
}
})
- if (res.status != 200) {
+ if (res.status == 0) {
+ // 通信断 (DNS 失敗・接続拒否・タイムアウト)。NoteDeck は throw せず status 0 で返す (notedeck#1294)
+ { error: "通信できません" }
+ } elif (res.status != 200) {
{ error: `取得失敗 (HTTP {res.status})` }
} else {
{ history: Json:parse(res.body) }
diff --git a/public/registry/widgets/crypto/api.json b/public/registry/widgets/crypto/api.json
index 8df6386..f476172 100644
--- a/public/registry/widgets/crypto/api.json
+++ b/public/registry/widgets/crypto/api.json
@@ -1 +1 @@
-{"type":"widget","data":"/// @ 1.2.1\n/// 暗号資産ウィジェット\n/// CoinGecko で暗号資産の価格・24時間変化率・7日トレンドを表示。API キー不要。\n/// coins/markets エンドポイントを使い、スパークライン(7日)も併せて取得する。\n/// 見た目は CoinMarketCap のランキング表を意識した monospace 整列レイアウト。\n/// 銘柄・通貨を変えるには下の DEFAULT_IDS / DEFAULT_VS を直接編集する。\n/// DEFAULT_IDS: CoinGecko ID をカンマ区切り (例: bitcoin,ethereum,solana)\n/// DEFAULT_VS : 表示通貨 (jpy / usd / eur)\n\nlet DEFAULT_IDS = \"bitcoin,ethereum,solana\"\nlet DEFAULT_VS = \"jpy\"\nlet WIDTH = 24 // monospace 整列の桁幅 (半角換算)\n\nvar lastStatus = 0 // 直近の HTTP ステータス (エラー表示用)\n\n// CoinMarketCap 風カラー\nlet COL_UP = \"16c784\"\nlet COL_DOWN = \"ea3943\"\nlet COL_FLAT = \"8a919e\"\nlet COL_DIM = \"5b6470\"\nlet COL_NAME = \"eff2f5\"\nlet COL_PRICE = \"d0d5dd\"\n\n// ─────────────────────────────\n// ヘルパー\n// ─────────────────────────────\n@dflt(v, d) { if (Core:type(v) == \"null\") d else v }\n\n@get(o, k, d) {\n if (Core:type(o) != \"obj\") { d }\n elif (Obj:has(o, k)) { dflt(o[k], d) }\n else { d }\n}\n\n@round2(x) {\n if (Core:type(x) == \"null\") { 0 }\n else { Math:round(x * 100) / 100 }\n}\n\n@pad2(n) { if (n < 10) { `0{n}` } else { `{n}` } }\n\n/// 同じ文字を n 個並べた文字列\n@repeatStr(ch, n) {\n var s = \"\"\n if (n > 0) { for (let i, n) { s = `{s}{ch}` } }\n s\n}\n\n/// 左セグメントと右セグメントを幅 w に揃える (間をスペース埋め)\n/// lPlain/rPlain は幅計算用の素テキスト、lShow/rShow は装飾込みの表示テキスト\n@justify(lPlain, lShow, rPlain, rShow, w) {\n let gap = w - lPlain.len - rPlain.len\n `{lShow}{repeatStr(\" \", if (gap > 0) { gap } else { 1 })}{rShow}`\n}\n\n@vsSymbol(vs) {\n if (vs == \"jpy\") { \"¥\" }\n elif (vs == \"usd\") { \"$\" }\n elif (vs == \"eur\") { \"€\" }\n else { \"\" }\n}\n\n/// 非負整数に3桁区切りのカンマを挿入 (再帰)\n@addCommas(n) {\n if (n < 1000) { `{n}` }\n else {\n let head = addCommas(Math:floor(n / 1000))\n let tail = n % 1000\n let t = if (tail < 10) { `00{tail}` } elif (tail < 100) { `0{tail}` } else { `{tail}` }\n `{head},{t}`\n }\n}\n\n/// 価格を通貨記号付きで整形 (価格帯に応じて桁数を調整)\n@fmtPrice(p, vs) {\n let sym = vsSymbol(vs)\n if (Core:type(p) == \"null\") { `{sym}-` }\n elif (p >= 100) { `{sym}{addCommas(Math:round(p))}` }\n elif (p >= 1) { `{sym}{round2(p)}` }\n else { `{sym}{Math:round(p * 10000) / 10000}` }\n}\n\n/// CoinGecko ID → 通貨を表す絵文字 (おおむねブランドカラー)。\n/// 登録のある銘柄だけ表示し、未登録は記号なし。\n@symbolFor(id) {\n if (id == \"bitcoin\") { \"🟠\" }\n elif (id == \"ethereum\") { \"🔷\" }\n elif (id == \"tether\") { \"🟢\" }\n elif (id == \"litecoin\") { \"⚪\" }\n elif (id == \"dogecoin\") { \"🐕\" }\n elif (id == \"cardano\") { \"🔵\" }\n elif (id == \"solana\") { \"🟣\" }\n elif (id == \"monero\") { \"🔶\" }\n elif (id == \"binancecoin\") { \"🟡\" }\n elif (id == \"ripple\") { \"⚫\" }\n elif (id == \"polkadot\") { \"🔴\" }\n elif (id == \"tron\") { \"🔻\" }\n else { \"\" }\n}\n\n@chgArrow(chg) {\n if (Core:type(chg) == \"null\") { \"■\" }\n elif (chg > 0) { \"▲\" }\n elif (chg < 0) { \"▼\" }\n else { \"■\" }\n}\n\n@chgColor(chg) {\n if (Core:type(chg) == \"null\") { COL_FLAT }\n elif (chg > 0) { COL_UP }\n elif (chg < 0) { COL_DOWN }\n else { COL_FLAT }\n}\n\n/// 7日価格配列をブロック文字のスパークラインに変換 (素テキスト)\n@sparkText(prices, points) {\n if (Core:type(prices) != \"arr\") { return \"\" }\n let plen = prices.len\n if (plen < 2) { return \"\" }\n let blocks = [\"▁\", \"▂\", \"▃\", \"▄\", \"▅\", \"▆\", \"▇\", \"█\"]\n let m = if (plen > points) { points } else { plen }\n let stride = plen / m\n var sampled = []\n for (let k, m) { sampled.push(prices[Math:floor(k * stride)]) }\n var mn = sampled[0]\n var mx = sampled[0]\n for (let j, m) {\n if (sampled[j] < mn) { mn = sampled[j] }\n if (sampled[j] > mx) { mx = sampled[j] }\n }\n let span = mx - mn\n var bars = \"\"\n for (let j, m) {\n let lvl = if (span == 0) { 0 } else { Math:round((sampled[j] - mn) / span * 7) }\n bars = `{bars}{blocks[lvl]}`\n }\n bars\n}\n\n// ─────────────────────────────\n// データ取得\n// ─────────────────────────────\n@fetchMarkets(ids, vs) {\n let url = `https://api.coingecko.com/api/v3/coins/markets?vs_currency={vs}&ids={ids}&order=market_cap_desc&sparkline=true&price_change_percentage=24h`\n let res = Nd:http(url, {\n method: \"GET\"\n headers: { \"Accept\": \"application/json\" }\n })\n lastStatus = res.status\n if (res.status != 200) { return null }\n Json:parse(res.body)\n}\n\n/// レスポンス配列から指定 ID の銘柄を探す (見つからなければ null)\n@findCoin(arr, id) {\n if (Core:type(arr) != \"arr\") { return null }\n var found = null\n for (let i, arr.len) {\n if (get(arr[i], \"id\", \"\") == id) { found = arr[i] }\n }\n found\n}\n\n// ─────────────────────────────\n// UI 構築\n// ─────────────────────────────\n/// 1 銘柄 = 2 行カード (CoinMarketCap 風)\n/// 行1: 順位 名称 ……… 価格\n/// 行2: ▲変化率 ……… 7日スパークライン\n@coinCard(coin, rank, vs) {\n let nm = get(coin, \"name\", \"?\")\n let nmShort = if (nm.len > 8) { nm.slice(0, 8) } else { nm }\n let sym = symbolFor(get(coin, \"id\", \"\"))\n let name = if (sym != \"\") { `{sym} {nmShort}` } else { nmShort }\n let price = get(coin, \"current_price\", null)\n let chg = get(coin, \"price_change_percentage_24h\", null)\n let col = chgColor(chg)\n\n // 行1: 順位 + 名称 ……… 価格\n let l1Plain = `{rank} {name}`\n let l1Show = `$[fg.color={COL_DIM} {rank}] $[fg.color={COL_NAME} **{name}**]`\n let priceStr = fmtPrice(price, vs)\n let r1Show = `$[fg.color={COL_PRICE} {priceStr}]`\n let line1 = justify(l1Plain, l1Show, priceStr, r1Show, WIDTH)\n\n // 行2: ▲変化率 ……… スパークライン\n let chgV = if (Core:type(chg) == \"null\") { \"-\" } else { `{round2(chg)}` }\n let l2Plain = ` {chgArrow(chg)} {chgV}%`\n let l2Show = ` $[fg.color={col} {chgArrow(chg)} {chgV}%]`\n let sp = sparkText(get(get(coin, \"sparkline_in_7d\", {}), \"price\", []), 12)\n let r2Show = `$[fg.color={col} {sp}]`\n let line2 = justify(l2Plain, l2Show, sp, r2Show, WIDTH)\n\n [\n Ui:C:mfm({ text: line1, font: \"monospace\" })\n Ui:C:mfm({ text: line2, font: \"monospace\" })\n ]\n}\n\n/// 取得できなかった銘柄の行\n@missCard(id, rank) {\n [Ui:C:mfm({ text: `$[fg.color={COL_DIM} {rank}] $[fg.color={COL_FLAT} {id} — データなし]`, font: \"monospace\" })]\n}\n\n@cryptoView(data, ids, vs) {\n let idList = ids.replace(\" \", \"\").split(\",\")\n var coins = []\n for (let i, idList.len) {\n let id = idList[i]\n if (id != \"\") { coins.push({ id: id, coin: findCoin(data, id) }) }\n }\n if (coins.len == 0) {\n return [Ui:C:mfm({ text: \"$[fg.color=8a919e 銘柄が設定されていません]\" })]\n }\n\n // タイトル行に更新時刻 (HH:MM) を統合\n let now = Date:now()\n var body = [\n Ui:C:mfm({ text: `**暗号資産** $[fg.color={COL_DIM} 更新 {pad2(Date:hour(now))}:{pad2(Date:minute(now))}]` })\n ]\n\n // 区切り線なし。銘柄カードを連続表示\n for (let i, coins.len) {\n let e = coins[i]\n let rank = `{i + 1}`\n if (Core:type(e.coin) != \"null\") {\n body = body.concat(coinCard(e.coin, rank, vs))\n } else {\n body = body.concat(missCard(e.id, rank))\n }\n }\n body\n}\n\nvar firstRun = true\nvar hasData = false // 一度でも価格表示に成功したか\n\n@render() {\n let ids = DEFAULT_IDS\n let vs = DEFAULT_VS\n\n if (firstRun) {\n Ui:render([\n Ui:C:container({\n align: \"center\"\n children: [Ui:C:text({ text: \"価格を読み込み中…\" })]\n })\n ])\n }\n firstRun = false\n\n let data = fetchMarkets(ids, vs)\n if (Core:type(data) != \"arr\") {\n // 取得失敗 (一時的なレート制限など)。既に表示があればそれを維持し、\n // 画面のチラつきを防ぐ。初回失敗時のみエラーを表示する。\n if (hasData) { return null }\n let hint = if (lastStatus == 429) { \"レート制限中。しばらく待つと回復します\" } else { \"通信エラー\" }\n Ui:render([\n Ui:C:container({\n align: \"center\"\n children: [\n Ui:C:mfm({ text: `$[fg.color=ea3943 ⚠️ 価格データを取得できません (HTTP {lastStatus})]` })\n Ui:C:mfm({ text: `$[fg.color={COL_FLAT} {hint}]` })\n ]\n })\n ])\n return null\n }\n hasData = true\n\n let children = cryptoView(data, ids, vs)\n Ui:render([Ui:C:container({ align: \"center\", children: children })])\n}\n\n// keyless でも 1 更新 = 1 リクエストなので 5 分間隔なら安全圏 (~0.2 req/min)。\n// 取得失敗時は render が前回表示を維持する (hasData によるチラ防止)。\nrender()\nAsync:interval(300000, render, false)"}
+{"type":"widget","data":"/// @ 1.2.1\n/// 暗号資産ウィジェット\n/// CoinGecko で暗号資産の価格・24時間変化率・7日トレンドを表示。API キー不要。\n/// coins/markets エンドポイントを使い、スパークライン(7日)も併せて取得する。\n/// 見た目は CoinMarketCap のランキング表を意識した monospace 整列レイアウト。\n/// 銘柄・通貨を変えるには下の DEFAULT_IDS / DEFAULT_VS を直接編集する。\n/// DEFAULT_IDS: CoinGecko ID をカンマ区切り (例: bitcoin,ethereum,solana)\n/// DEFAULT_VS : 表示通貨 (jpy / usd / eur)\n\nlet DEFAULT_IDS = \"bitcoin,ethereum,solana\"\nlet DEFAULT_VS = \"jpy\"\nlet WIDTH = 24 // monospace 整列の桁幅 (半角換算)\n\nvar lastStatus = 0 // 直近の HTTP ステータス (エラー表示用)\n/// Nd:http の失敗理由。status 0 は通信断 (DNS 失敗・接続拒否・タイムアウト) で、\n/// NoteDeck は throw せず値で返す (notedeck#1294)\n@httpLabel(st) { if (st == 0) { \"通信できません\" } else { `HTTP {st}` } }\n\n// CoinMarketCap 風カラー\nlet COL_UP = \"16c784\"\nlet COL_DOWN = \"ea3943\"\nlet COL_FLAT = \"8a919e\"\nlet COL_DIM = \"5b6470\"\nlet COL_NAME = \"eff2f5\"\nlet COL_PRICE = \"d0d5dd\"\n\n// ─────────────────────────────\n// ヘルパー\n// ─────────────────────────────\n@dflt(v, d) { if (Core:type(v) == \"null\") d else v }\n\n@get(o, k, d) {\n if (Core:type(o) != \"obj\") { d }\n elif (Obj:has(o, k)) { dflt(o[k], d) }\n else { d }\n}\n\n@round2(x) {\n if (Core:type(x) == \"null\") { 0 }\n else { Math:round(x * 100) / 100 }\n}\n\n@pad2(n) { if (n < 10) { `0{n}` } else { `{n}` } }\n\n/// 同じ文字を n 個並べた文字列\n@repeatStr(ch, n) {\n var s = \"\"\n if (n > 0) { for (let i, n) { s = `{s}{ch}` } }\n s\n}\n\n/// 左セグメントと右セグメントを幅 w に揃える (間をスペース埋め)\n/// lPlain/rPlain は幅計算用の素テキスト、lShow/rShow は装飾込みの表示テキスト\n@justify(lPlain, lShow, rPlain, rShow, w) {\n let gap = w - lPlain.len - rPlain.len\n `{lShow}{repeatStr(\" \", if (gap > 0) { gap } else { 1 })}{rShow}`\n}\n\n@vsSymbol(vs) {\n if (vs == \"jpy\") { \"¥\" }\n elif (vs == \"usd\") { \"$\" }\n elif (vs == \"eur\") { \"€\" }\n else { \"\" }\n}\n\n/// 非負整数に3桁区切りのカンマを挿入 (再帰)\n@addCommas(n) {\n if (n < 1000) { `{n}` }\n else {\n let head = addCommas(Math:floor(n / 1000))\n let tail = n % 1000\n let t = if (tail < 10) { `00{tail}` } elif (tail < 100) { `0{tail}` } else { `{tail}` }\n `{head},{t}`\n }\n}\n\n/// 価格を通貨記号付きで整形 (価格帯に応じて桁数を調整)\n@fmtPrice(p, vs) {\n let sym = vsSymbol(vs)\n if (Core:type(p) == \"null\") { `{sym}-` }\n elif (p >= 100) { `{sym}{addCommas(Math:round(p))}` }\n elif (p >= 1) { `{sym}{round2(p)}` }\n else { `{sym}{Math:round(p * 10000) / 10000}` }\n}\n\n/// CoinGecko ID → 通貨を表す絵文字 (おおむねブランドカラー)。\n/// 登録のある銘柄だけ表示し、未登録は記号なし。\n@symbolFor(id) {\n if (id == \"bitcoin\") { \"🟠\" }\n elif (id == \"ethereum\") { \"🔷\" }\n elif (id == \"tether\") { \"🟢\" }\n elif (id == \"litecoin\") { \"⚪\" }\n elif (id == \"dogecoin\") { \"🐕\" }\n elif (id == \"cardano\") { \"🔵\" }\n elif (id == \"solana\") { \"🟣\" }\n elif (id == \"monero\") { \"🔶\" }\n elif (id == \"binancecoin\") { \"🟡\" }\n elif (id == \"ripple\") { \"⚫\" }\n elif (id == \"polkadot\") { \"🔴\" }\n elif (id == \"tron\") { \"🔻\" }\n else { \"\" }\n}\n\n@chgArrow(chg) {\n if (Core:type(chg) == \"null\") { \"■\" }\n elif (chg > 0) { \"▲\" }\n elif (chg < 0) { \"▼\" }\n else { \"■\" }\n}\n\n@chgColor(chg) {\n if (Core:type(chg) == \"null\") { COL_FLAT }\n elif (chg > 0) { COL_UP }\n elif (chg < 0) { COL_DOWN }\n else { COL_FLAT }\n}\n\n/// 7日価格配列をブロック文字のスパークラインに変換 (素テキスト)\n@sparkText(prices, points) {\n if (Core:type(prices) != \"arr\") { return \"\" }\n let plen = prices.len\n if (plen < 2) { return \"\" }\n let blocks = [\"▁\", \"▂\", \"▃\", \"▄\", \"▅\", \"▆\", \"▇\", \"█\"]\n let m = if (plen > points) { points } else { plen }\n let stride = plen / m\n var sampled = []\n for (let k, m) { sampled.push(prices[Math:floor(k * stride)]) }\n var mn = sampled[0]\n var mx = sampled[0]\n for (let j, m) {\n if (sampled[j] < mn) { mn = sampled[j] }\n if (sampled[j] > mx) { mx = sampled[j] }\n }\n let span = mx - mn\n var bars = \"\"\n for (let j, m) {\n let lvl = if (span == 0) { 0 } else { Math:round((sampled[j] - mn) / span * 7) }\n bars = `{bars}{blocks[lvl]}`\n }\n bars\n}\n\n// ─────────────────────────────\n// データ取得\n// ─────────────────────────────\n@fetchMarkets(ids, vs) {\n let url = `https://api.coingecko.com/api/v3/coins/markets?vs_currency={vs}&ids={ids}&order=market_cap_desc&sparkline=true&price_change_percentage=24h`\n let res = Nd:http(url, {\n method: \"GET\"\n headers: { \"Accept\": \"application/json\" }\n })\n lastStatus = res.status\n if (res.status != 200) { return null }\n Json:parse(res.body)\n}\n\n/// レスポンス配列から指定 ID の銘柄を探す (見つからなければ null)\n@findCoin(arr, id) {\n if (Core:type(arr) != \"arr\") { return null }\n var found = null\n for (let i, arr.len) {\n if (get(arr[i], \"id\", \"\") == id) { found = arr[i] }\n }\n found\n}\n\n// ─────────────────────────────\n// UI 構築\n// ─────────────────────────────\n/// 1 銘柄 = 2 行カード (CoinMarketCap 風)\n/// 行1: 順位 名称 ……… 価格\n/// 行2: ▲変化率 ……… 7日スパークライン\n@coinCard(coin, rank, vs) {\n let nm = get(coin, \"name\", \"?\")\n let nmShort = if (nm.len > 8) { nm.slice(0, 8) } else { nm }\n let sym = symbolFor(get(coin, \"id\", \"\"))\n let name = if (sym != \"\") { `{sym} {nmShort}` } else { nmShort }\n let price = get(coin, \"current_price\", null)\n let chg = get(coin, \"price_change_percentage_24h\", null)\n let col = chgColor(chg)\n\n // 行1: 順位 + 名称 ……… 価格\n let l1Plain = `{rank} {name}`\n let l1Show = `$[fg.color={COL_DIM} {rank}] $[fg.color={COL_NAME} **{name}**]`\n let priceStr = fmtPrice(price, vs)\n let r1Show = `$[fg.color={COL_PRICE} {priceStr}]`\n let line1 = justify(l1Plain, l1Show, priceStr, r1Show, WIDTH)\n\n // 行2: ▲変化率 ……… スパークライン\n let chgV = if (Core:type(chg) == \"null\") { \"-\" } else { `{round2(chg)}` }\n let l2Plain = ` {chgArrow(chg)} {chgV}%`\n let l2Show = ` $[fg.color={col} {chgArrow(chg)} {chgV}%]`\n let sp = sparkText(get(get(coin, \"sparkline_in_7d\", {}), \"price\", []), 12)\n let r2Show = `$[fg.color={col} {sp}]`\n let line2 = justify(l2Plain, l2Show, sp, r2Show, WIDTH)\n\n [\n Ui:C:mfm({ text: line1, font: \"monospace\" })\n Ui:C:mfm({ text: line2, font: \"monospace\" })\n ]\n}\n\n/// 取得できなかった銘柄の行\n@missCard(id, rank) {\n [Ui:C:mfm({ text: `$[fg.color={COL_DIM} {rank}] $[fg.color={COL_FLAT} {id} — データなし]`, font: \"monospace\" })]\n}\n\n@cryptoView(data, ids, vs) {\n let idList = ids.replace(\" \", \"\").split(\",\")\n var coins = []\n for (let i, idList.len) {\n let id = idList[i]\n if (id != \"\") { coins.push({ id: id, coin: findCoin(data, id) }) }\n }\n if (coins.len == 0) {\n return [Ui:C:mfm({ text: \"$[fg.color=8a919e 銘柄が設定されていません]\" })]\n }\n\n // タイトル行に更新時刻 (HH:MM) を統合\n let now = Date:now()\n var body = [\n Ui:C:mfm({ text: `**暗号資産** $[fg.color={COL_DIM} 更新 {pad2(Date:hour(now))}:{pad2(Date:minute(now))}]` })\n ]\n\n // 区切り線なし。銘柄カードを連続表示\n for (let i, coins.len) {\n let e = coins[i]\n let rank = `{i + 1}`\n if (Core:type(e.coin) != \"null\") {\n body = body.concat(coinCard(e.coin, rank, vs))\n } else {\n body = body.concat(missCard(e.id, rank))\n }\n }\n body\n}\n\nvar firstRun = true\nvar hasData = false // 一度でも価格表示に成功したか\n\n@render() {\n let ids = DEFAULT_IDS\n let vs = DEFAULT_VS\n\n if (firstRun) {\n Ui:render([\n Ui:C:container({\n align: \"center\"\n children: [Ui:C:text({ text: \"価格を読み込み中…\" })]\n })\n ])\n }\n firstRun = false\n\n let data = fetchMarkets(ids, vs)\n if (Core:type(data) != \"arr\") {\n // 取得失敗 (一時的なレート制限など)。既に表示があればそれを維持し、\n // 画面のチラつきを防ぐ。初回失敗時のみエラーを表示する。\n if (hasData) { return null }\n let hint = if (lastStatus == 429) { \"レート制限中。しばらく待つと回復します\" } else { \"通信エラー\" }\n Ui:render([\n Ui:C:container({\n align: \"center\"\n children: [\n Ui:C:mfm({ text: `$[fg.color=ea3943 ⚠️ 価格データを取得できません ({httpLabel(lastStatus)})]` })\n Ui:C:mfm({ text: `$[fg.color={COL_FLAT} {hint}]` })\n ]\n })\n ])\n return null\n }\n hasData = true\n\n let children = cryptoView(data, ids, vs)\n Ui:render([Ui:C:container({ align: \"center\", children: children })])\n}\n\n// keyless でも 1 更新 = 1 リクエストなので 5 分間隔なら安全圏 (~0.2 req/min)。\n// 取得失敗時は render が前回表示を維持する (hasData によるチラ防止)。\nrender()\nAsync:interval(300000, render, false)"}
diff --git a/public/registry/widgets/crypto/widget.is b/public/registry/widgets/crypto/widget.is
index 941927c..a05e3d9 100644
--- a/public/registry/widgets/crypto/widget.is
+++ b/public/registry/widgets/crypto/widget.is
@@ -12,6 +12,9 @@ let DEFAULT_VS = "jpy"
let WIDTH = 24 // monospace 整列の桁幅 (半角換算)
var lastStatus = 0 // 直近の HTTP ステータス (エラー表示用)
+/// Nd:http の失敗理由。status 0 は通信断 (DNS 失敗・接続拒否・タイムアウト) で、
+/// NoteDeck は throw せず値で返す (notedeck#1294)
+@httpLabel(st) { if (st == 0) { "通信できません" } else { `HTTP {st}` } }
// CoinMarketCap 風カラー
let COL_UP = "16c784"
@@ -259,7 +262,7 @@ var hasData = false // 一度でも価格表示に成功したか
Ui:C:container({
align: "center"
children: [
- Ui:C:mfm({ text: `$[fg.color=ea3943 ⚠️ 価格データを取得できません (HTTP {lastStatus})]` })
+ Ui:C:mfm({ text: `$[fg.color=ea3943 ⚠️ 価格データを取得できません ({httpLabel(lastStatus)})]` })
Ui:C:mfm({ text: `$[fg.color={COL_FLAT} {hint}]` })
]
})
diff --git a/public/registry/widgets/ctftime/api.json b/public/registry/widgets/ctftime/api.json
index b57ac11..dc7c0e6 100644
--- a/public/registry/widgets/ctftime/api.json
+++ b/public/registry/widgets/ctftime/api.json
@@ -1 +1 @@
-{"type":"widget","data":"/// @ 1.2.1\n/// CTFTime ウィジェット\n/// CTFTime 公式 API から今後の CTF イベントを開催が近い順に表示。API キー不要。\n/// 開始までのカウントダウン・規模(weight)・形式・オンライン/オンサイトを併記する。\n/// 設計の元ネタ: zenn / atcoder ウィジェット (表示・更新ループ)。\n\n// 表示するイベント件数。ここを書き換えると増減できる。\nlet COUNT = 6\n\n// アクセント・テキスト色\nlet ACCENT = \"e01e5a\"\nlet MUTED = \"8a96a3\"\nlet LINE = \"5a6573\"\nlet LIVE = \"ff4d4f\"\n\n@dflt(v, d) {\n if (Core:type(v) == \"null\") d else v\n}\n\n@get(o, k, d) {\n if (Core:type(o) != \"obj\") {\n d\n } elif (Obj:has(o, k)) {\n dflt(o[k], d)\n } else {\n d\n }\n}\n\nvar firstRun = true\n\n/// ISO 8601 (\"2026-07-04T02:00:00+00:00\") を \"07/04 02:00\" にする (UTC)\n@fmtDate(s) {\n if (Core:type(s) == \"str\" && s.len >= 16) {\n `{s.slice(5, 10).replace(\"-\", \"/\")} {s.slice(11, 16)}`\n } else {\n \"-\"\n }\n}\n\n/// 規模(weight)に応じた色 (大規模ほど目立たせる)\n@weightColor(w) {\n if (Core:type(w) != \"num\") MUTED\n elif (w >= 50) \"ff4d4f\"\n elif (w >= 25) \"ff8000\"\n elif (w > 0) \"43a047\"\n else MUTED\n}\n\n/// 規模ラベル\n@weightLabel(w) {\n if (Core:type(w) == \"num\" && w > 0) `w{Math:round(w)}` else \"w—\"\n}\n\n/// オンライン / オンサイト\n@venueLabel(onsite) {\n if (onsite) \"🏛 onsite\" else \"🌐 online\"\n}\n\n/// 開催までの残り時間 / 開催中判定\n@countdown(startS, finishS) {\n let startMs = Date:parse(startS)\n let finishMs = Date:parse(finishS)\n let nowMs = Date:now()\n if (startMs <= nowMs && finishMs > nowMs) {\n `$[fg.color={LIVE} 🔴 開催中]`\n } else {\n let diff = startMs - nowMs\n let days = Math:floor(diff / 86400000)\n if (days >= 1) {\n `$[fg.color={ACCENT} あと{days}日]`\n } else {\n let hours = Math:floor(diff / 3600000)\n if (hours >= 1) {\n `$[fg.color={ACCENT} あと{hours}時間]`\n } else {\n let mins = Math:floor(diff / 60000)\n `$[fg.color={ACCENT} あと{mins}分]`\n }\n }\n }\n}\n\n/// 規模色の小さな丸\n@dot(color) {\n `$[fg.color={color} ●]`\n}\n\n/// 横区切り線\n@divider() {\n Ui:C:mfm({ text: `$[fg.color={LINE} ───────────────]` })\n}\n\n@fetchEvents() {\n let nowSec = Math:floor(Date:now() / 1000)\n // 前日開始の開催中イベントも拾えるよう start は 1 日前から。90 日先まで。\n let startSec = nowSec - 86400\n let finishSec = nowSec + 7776000\n let url = `https://ctftime.org/api/v1/events/?limit={COUNT + 8}&start={startSec}&finish={finishSec}`\n let res = Nd:http(url, {\n method: \"GET\"\n headers: { \"Accept\": \"application/json\" }\n })\n if (res.status != 200) {\n { error: `取得失敗 (HTTP {res.status})` }\n } else {\n { events: Json:parse(res.body) }\n }\n}\n\n/// 1 件のイベントを 2 段 (2 要素) のリスト項目にする\n@eventRow(e) {\n let title = get(e, \"title\", \"(CTF)\")\n let url = get(e, \"ctftime_url\", \"https://ctftime.org/\")\n let start = get(e, \"start\", \"\")\n let finish = get(e, \"finish\", \"\")\n let weight = get(e, \"weight\", 0)\n let fmt = get(e, \"format\", \"\")\n let onsite = get(e, \"onsite\", false)\n [\n Ui:C:mfm({ text: `{dot(weightColor(weight))} [{title}]({url})` })\n Ui:C:mfm({ text: ` {countdown(start, finish)} $[fg.color={MUTED} ・ {fmtDate(start)} ・ {fmt} ・ {venueLabel(onsite)} ・ {weightLabel(weight)}]` })\n ]\n}\n\n@eventsView() {\n let data = fetchEvents()\n\n // 取得失敗\n if (Core:type(get(data, \"error\", null)) != \"null\") {\n return [\n Ui:C:mfm({ text: `$[fg.color={ACCENT} 🚩 CTFTime]` })\n Ui:C:mfm({ text: `$[fg.color=f74e52 ⚠️ {data.error}]` })\n ]\n }\n\n let events = get(data, \"events\", [])\n if (Core:type(events) != \"arr\" || events.len == 0) {\n return [\n Ui:C:mfm({ text: `$[fg.color={ACCENT} 🚩 CTFTime]` })\n Ui:C:mfm({ text: `$[fg.color={MUTED} 📭 予定されている CTF がありません]` })\n ]\n }\n\n // 開催中 / 今後のみ (終了済みを除外)\n let nowMs = Date:now()\n var upcoming = []\n for (let i, events.len) {\n let e = events[i]\n if (Date:parse(get(e, \"finish\", \"\")) > nowMs) {\n upcoming = upcoming.concat([ e ])\n }\n }\n\n var body = [\n Ui:C:mfm({ text: `$[fg.color={ACCENT} **🚩 CTFTime**] $[fg.color={MUTED} 今後の CTF]` })\n divider()\n ]\n\n if (upcoming.len == 0) {\n body = body.concat([\n Ui:C:mfm({ text: `$[fg.color={MUTED} 📭 直近に予定されている CTF がありません]` })\n ])\n } else {\n var shown = 0\n for (let i, upcoming.len) {\n if (shown < COUNT) {\n body = body.concat(eventRow(upcoming[i]))\n shown = shown + 1\n }\n }\n }\n\n // フッター導線\n body.concat([\n divider()\n Ui:C:mfm({ text: `[CTFTime のイベント一覧 →](https://ctftime.org/event/list/upcoming)` })\n ])\n}\n\n@render() {\n if (firstRun) {\n Ui:render([\n Ui:C:container({\n align: \"center\"\n children: [ Ui:C:text({ text: \"CTF イベントを読み込み中… 🚩\" }) ]\n })\n ])\n }\n firstRun = false\n\n let body = eventsView()\n Ui:render([\n Ui:C:container({\n children: body\n })\n ])\n}\n\nrender()\n// イベント予定は頻繁に変わらないので 30 分間隔で更新\nAsync:interval(1800000, render, false)\n"}
+{"type":"widget","data":"/// @ 1.2.1\n/// CTFTime ウィジェット\n/// CTFTime 公式 API から今後の CTF イベントを開催が近い順に表示。API キー不要。\n/// 開始までのカウントダウン・規模(weight)・形式・オンライン/オンサイトを併記する。\n/// 設計の元ネタ: zenn / atcoder ウィジェット (表示・更新ループ)。\n\n// 表示するイベント件数。ここを書き換えると増減できる。\nlet COUNT = 6\n\n// アクセント・テキスト色\nlet ACCENT = \"e01e5a\"\nlet MUTED = \"8a96a3\"\nlet LINE = \"5a6573\"\nlet LIVE = \"ff4d4f\"\n\n@dflt(v, d) {\n if (Core:type(v) == \"null\") d else v\n}\n\n@get(o, k, d) {\n if (Core:type(o) != \"obj\") {\n d\n } elif (Obj:has(o, k)) {\n dflt(o[k], d)\n } else {\n d\n }\n}\n\nvar firstRun = true\n\n/// ISO 8601 (\"2026-07-04T02:00:00+00:00\") を \"07/04 02:00\" にする (UTC)\n@fmtDate(s) {\n if (Core:type(s) == \"str\" && s.len >= 16) {\n `{s.slice(5, 10).replace(\"-\", \"/\")} {s.slice(11, 16)}`\n } else {\n \"-\"\n }\n}\n\n/// 規模(weight)に応じた色 (大規模ほど目立たせる)\n@weightColor(w) {\n if (Core:type(w) != \"num\") MUTED\n elif (w >= 50) \"ff4d4f\"\n elif (w >= 25) \"ff8000\"\n elif (w > 0) \"43a047\"\n else MUTED\n}\n\n/// 規模ラベル\n@weightLabel(w) {\n if (Core:type(w) == \"num\" && w > 0) `w{Math:round(w)}` else \"w—\"\n}\n\n/// オンライン / オンサイト\n@venueLabel(onsite) {\n if (onsite) \"🏛 onsite\" else \"🌐 online\"\n}\n\n/// 開催までの残り時間 / 開催中判定\n@countdown(startS, finishS) {\n let startMs = Date:parse(startS)\n let finishMs = Date:parse(finishS)\n let nowMs = Date:now()\n if (startMs <= nowMs && finishMs > nowMs) {\n `$[fg.color={LIVE} 🔴 開催中]`\n } else {\n let diff = startMs - nowMs\n let days = Math:floor(diff / 86400000)\n if (days >= 1) {\n `$[fg.color={ACCENT} あと{days}日]`\n } else {\n let hours = Math:floor(diff / 3600000)\n if (hours >= 1) {\n `$[fg.color={ACCENT} あと{hours}時間]`\n } else {\n let mins = Math:floor(diff / 60000)\n `$[fg.color={ACCENT} あと{mins}分]`\n }\n }\n }\n}\n\n/// 規模色の小さな丸\n@dot(color) {\n `$[fg.color={color} ●]`\n}\n\n/// 横区切り線\n@divider() {\n Ui:C:mfm({ text: `$[fg.color={LINE} ───────────────]` })\n}\n\n@fetchEvents() {\n let nowSec = Math:floor(Date:now() / 1000)\n // 前日開始の開催中イベントも拾えるよう start は 1 日前から。90 日先まで。\n let startSec = nowSec - 86400\n let finishSec = nowSec + 7776000\n let url = `https://ctftime.org/api/v1/events/?limit={COUNT + 8}&start={startSec}&finish={finishSec}`\n let res = Nd:http(url, {\n method: \"GET\"\n headers: { \"Accept\": \"application/json\" }\n })\n if (res.status == 0) {\n // 通信断 (DNS 失敗・接続拒否・タイムアウト)。NoteDeck は throw せず status 0 で返す (notedeck#1294)\n { error: \"通信できません\" }\n } elif (res.status != 200) {\n { error: `取得失敗 (HTTP {res.status})` }\n } else {\n { events: Json:parse(res.body) }\n }\n}\n\n/// 1 件のイベントを 2 段 (2 要素) のリスト項目にする\n@eventRow(e) {\n let title = get(e, \"title\", \"(CTF)\")\n let url = get(e, \"ctftime_url\", \"https://ctftime.org/\")\n let start = get(e, \"start\", \"\")\n let finish = get(e, \"finish\", \"\")\n let weight = get(e, \"weight\", 0)\n let fmt = get(e, \"format\", \"\")\n let onsite = get(e, \"onsite\", false)\n [\n Ui:C:mfm({ text: `{dot(weightColor(weight))} [{title}]({url})` })\n Ui:C:mfm({ text: ` {countdown(start, finish)} $[fg.color={MUTED} ・ {fmtDate(start)} ・ {fmt} ・ {venueLabel(onsite)} ・ {weightLabel(weight)}]` })\n ]\n}\n\n@eventsView() {\n let data = fetchEvents()\n\n // 取得失敗\n if (Core:type(get(data, \"error\", null)) != \"null\") {\n return [\n Ui:C:mfm({ text: `$[fg.color={ACCENT} 🚩 CTFTime]` })\n Ui:C:mfm({ text: `$[fg.color=f74e52 ⚠️ {data.error}]` })\n ]\n }\n\n let events = get(data, \"events\", [])\n if (Core:type(events) != \"arr\" || events.len == 0) {\n return [\n Ui:C:mfm({ text: `$[fg.color={ACCENT} 🚩 CTFTime]` })\n Ui:C:mfm({ text: `$[fg.color={MUTED} 📭 予定されている CTF がありません]` })\n ]\n }\n\n // 開催中 / 今後のみ (終了済みを除外)\n let nowMs = Date:now()\n var upcoming = []\n for (let i, events.len) {\n let e = events[i]\n if (Date:parse(get(e, \"finish\", \"\")) > nowMs) {\n upcoming = upcoming.concat([ e ])\n }\n }\n\n var body = [\n Ui:C:mfm({ text: `$[fg.color={ACCENT} **🚩 CTFTime**] $[fg.color={MUTED} 今後の CTF]` })\n divider()\n ]\n\n if (upcoming.len == 0) {\n body = body.concat([\n Ui:C:mfm({ text: `$[fg.color={MUTED} 📭 直近に予定されている CTF がありません]` })\n ])\n } else {\n var shown = 0\n for (let i, upcoming.len) {\n if (shown < COUNT) {\n body = body.concat(eventRow(upcoming[i]))\n shown = shown + 1\n }\n }\n }\n\n // フッター導線\n body.concat([\n divider()\n Ui:C:mfm({ text: `[CTFTime のイベント一覧 →](https://ctftime.org/event/list/upcoming)` })\n ])\n}\n\n@render() {\n if (firstRun) {\n Ui:render([\n Ui:C:container({\n align: \"center\"\n children: [ Ui:C:text({ text: \"CTF イベントを読み込み中… 🚩\" }) ]\n })\n ])\n }\n firstRun = false\n\n let body = eventsView()\n Ui:render([\n Ui:C:container({\n children: body\n })\n ])\n}\n\nrender()\n// イベント予定は頻繁に変わらないので 30 分間隔で更新\nAsync:interval(1800000, render, false)\n"}
diff --git a/public/registry/widgets/ctftime/widget.is b/public/registry/widgets/ctftime/widget.is
index 00fa7c8..60ff115 100644
--- a/public/registry/widgets/ctftime/widget.is
+++ b/public/registry/widgets/ctftime/widget.is
@@ -101,7 +101,10 @@ var firstRun = true
method: "GET"
headers: { "Accept": "application/json" }
})
- if (res.status != 200) {
+ if (res.status == 0) {
+ // 通信断 (DNS 失敗・接続拒否・タイムアウト)。NoteDeck は throw せず status 0 で返す (notedeck#1294)
+ { error: "通信できません" }
+ } elif (res.status != 200) {
{ error: `取得失敗 (HTTP {res.status})` }
} else {
{ events: Json:parse(res.body) }
diff --git a/public/registry/widgets/cve/api.json b/public/registry/widgets/cve/api.json
index b1b8e07..db9ba88 100644
--- a/public/registry/widgets/cve/api.json
+++ b/public/registry/widgets/cve/api.json
@@ -1 +1 @@
-{"type":"widget","data":"/// @ 1.2.1\n/// CVE 脆弱性ウィジェット\n/// NVD (NIST National Vulnerability Database) から直近の重大な脆弱性 (CVE) を\n/// CVSS スコア・重大度付きで一覧表示する。API キー不要 (公開 API を直接叩く)。\n///\n/// NVD のデフォルトの並びは古い CVE から (CVE-1999-... 等) なので、\n/// lastModStartDate/lastModEndDate で「直近 N 日に更新された CVE」に絞り込み、\n/// クライアント側で lastModified の新しい順にソートして上位を表示する。\n///\n/// 設定は下の定数を直接編集する:\n/// SEVERITIES : 取得する重大度 (カンマ区切り)。CRITICAL,HIGH,MEDIUM,LOW\n/// DAYS : 何日前まで遡るか (NVD の制限上 最大 120)\n/// MAX_ITEMS : 表示する最大件数\n///\n/// 認証なしのレート制限は 30 秒あたり 5 リクエスト。重大度の数だけ叩くので\n/// SEVERITIES は 1〜3 個程度に留めること。\n\nlet SEVERITIES = \"CRITICAL,HIGH\" // 取得する重大度 (カンマ区切り)\nlet DAYS = 7 // 直近何日ぶんを対象にするか\nlet MAX_ITEMS = 6 // 表示する最大件数\nlet PER_SEV = 6 // 重大度ごとの取得件数 (NVD への 1 リクエスト分。大きいと応答が肥大化)\nlet WIDTH = 34 // monospace 整列の桁幅 (半角換算)\nlet DESC_LEN = 60 // 説明文の最大表示文字数\n\nvar lastStatus = 0 // 直近の HTTP ステータス (エラー表示用)\n\n// stocks/crypto ウィジェットと揃えたカラー\nlet COL_DIM = \"5b6470\"\nlet COL_FLAT = \"8a919e\"\nlet COL_DESC = \"b0b7c3\"\n\n// ─────────────────────────────\n// ヘルパー\n// ─────────────────────────────\n@dflt(v, d) { if (Core:type(v) == \"null\") d else v }\n\n@get(o, k, d) {\n if (Core:type(o) != \"obj\") { d }\n elif (Obj:has(o, k)) { dflt(o[k], d) }\n else { d }\n}\n\n@digitVal(c) {\n if (c == \"0\") { 0 } elif (c == \"1\") { 1 } elif (c == \"2\") { 2 }\n elif (c == \"3\") { 3 } elif (c == \"4\") { 4 } elif (c == \"5\") { 5 }\n elif (c == \"6\") { 6 } elif (c == \"7\") { 7 } elif (c == \"8\") { 8 }\n elif (c == \"9\") { 9 } else { -1 }\n}\n\n/// 数字のみの文字列を数値へ。失敗時 0。\n@strToNum(s) {\n if (Core:type(s) != \"str\" || s == \"\") { return 0 }\n var n = 0\n for (let i, s.len) {\n let d = digitVal(s.slice(i, i + 1))\n if (d < 0) { return 0 }\n n = n * 10 + d\n }\n n\n}\n\n@pad2(n) { if (n < 10) { `0{n}` } else { `{n}` } }\n\n/// 同じ文字を n 個並べた文字列\n@repeatStr(ch, n) {\n var s = \"\"\n if (n > 0) { for (let i, n) { s = `{s}{ch}` } }\n s\n}\n\n/// 左セグメントと右セグメントを幅 w に揃える (間をスペース埋め)\n@justify(lPlain, lShow, rPlain, rShow, w) {\n let gap = w - lPlain.len - rPlain.len\n `{lShow}{repeatStr(\" \", if (gap > 0) { gap } else { 1 })}{rShow}`\n}\n\n/// ISO 8601 タイムスタンプ (\"2026-06-16T21:48:22.267\") → 比較用の数値キー。失敗時 0。\n@tsKey(s) {\n if (Core:type(s) != \"str\" || s.len < 16) { return 0 }\n let y = strToNum(s.slice(0, 4))\n let mo = strToNum(s.slice(5, 7))\n let d = strToNum(s.slice(8, 10))\n let h = strToNum(s.slice(11, 13))\n let mi = strToNum(s.slice(14, 16))\n (((y * 100 + mo) * 100 + d) * 100 + h) * 100 + mi\n}\n\n/// ISO 8601 文字列の MM/DD 部分\n@monthDay(s) {\n if (Core:type(s) != \"str\" || s.len < 10) { \"\" }\n else { `{s.slice(5, 7)}/{s.slice(8, 10)}` }\n}\n\n/// NVD 日付パラメータ用に timestamp を ISO 8601 (コロンは %3A エンコード) へ\n@isoParam(t) {\n `{Date:year(t)}-{pad2(Date:month(t))}-{pad2(Date:day(t))}T{pad2(Date:hour(t))}%3A{pad2(Date:minute(t))}%3A00.000`\n}\n\n/// MFM の記号を無害化し改行を畳んだうえで切り詰める\n@cleanDesc(s) {\n if (Core:type(s) != \"str\") { return \"\" }\n var t = s\n t = t.replace(\"[\", \"(\")\n t = t.replace(\"]\", \")\")\n t = t.replace(\"`\", \"'\")\n t = t.replace(\"*\", \"・\")\n t = t.replace(\"@\", \"@\")\n t = t.replace(\"#\", \"#\")\n t = t.replace(\"$\", \"$\")\n if (t.len > DESC_LEN) { `{t.slice(0, DESC_LEN)}…` } else { t }\n}\n\n@sevColor(sev) {\n if (sev == \"CRITICAL\") { \"ea3943\" }\n elif (sev == \"HIGH\") { \"ff8c42\" }\n elif (sev == \"MEDIUM\") { \"f5a623\" }\n elif (sev == \"LOW\") { \"16c784\" }\n else { COL_FLAT }\n}\n\n@sevShort(sev) {\n if (sev == \"CRITICAL\") { \"CRIT\" }\n elif (sev == \"HIGH\") { \"HIGH\" }\n elif (sev == \"MEDIUM\") { \"MED\" }\n elif (sev == \"LOW\") { \"LOW\" }\n else { \"?\" }\n}\n\n@scoreStr(score) {\n if (Core:type(score) == \"null\") { \"-\" } else { `{score}` }\n}\n\n// ─────────────────────────────\n// データ取得・整形\n// ─────────────────────────────\n/// metrics から CVSS v3.1 → v3.0 の順で cvssData を取り出す\n@cvssData(m) {\n let v31 = get(m, \"cvssMetricV31\", [])\n if (Core:type(v31) == \"arr\" && v31.len > 0) { return get(v31[0], \"cvssData\", {}) }\n let v30 = get(m, \"cvssMetricV30\", [])\n if (Core:type(v30) == \"arr\" && v30.len > 0) { return get(v30[0], \"cvssData\", {}) }\n {}\n}\n\n/// cve オブジェクトから英語の説明文を取り出す\n@descOf(cve) {\n let ds = get(cve, \"descriptions\", [])\n if (Core:type(ds) != \"arr\") { return \"\" }\n for (let i, ds.len) {\n if (get(ds[i], \"lang\", \"\") == \"en\") { return get(ds[i], \"value\", \"\") }\n }\n if (ds.len > 0) { get(ds[0], \"value\", \"\") } else { \"\" }\n}\n\n/// NVD の vulnerabilities 要素 → 表示用の正規化オブジェクト\n@normalize(item) {\n let cve = get(item, \"cve\", {})\n let cd = cvssData(get(cve, \"metrics\", {}))\n let mod = get(cve, \"lastModified\", \"\")\n {\n id: get(cve, \"id\", \"?\")\n score: get(cd, \"baseScore\", null)\n sev: get(cd, \"baseSeverity\", \"\")\n pub: get(cve, \"published\", \"\")\n modKey: tsKey(mod)\n desc: cleanDesc(descOf(cve))\n }\n}\n\n/// 1 重大度ぶんを取得。vulnerabilities 配列を返す。失敗時 null。\n@fetchSeverity(sev, startEnc, endEnc) {\n let url = `https://services.nvd.nist.gov/rest/json/cves/2.0?cvssV3Severity={sev}&lastModStartDate={startEnc}&lastModEndDate={endEnc}&resultsPerPage={PER_SEV}`\n // Accept-Encoding: identity で gzip を明示的に拒否する。\n // NVD は gzip 応答を返すことがあり、ランタイムの自動展開に失敗して\n // \"error decoding response body\" となるため、非圧縮で受け取る。\n let res = Nd:http(url, {\n method: \"GET\"\n headers: {\n \"Accept\": \"application/json\"\n \"Accept-Encoding\": \"identity\"\n }\n })\n lastStatus = res.status\n if (res.status != 200) { return null }\n let doc = Json:parse(res.body)\n get(doc, \"vulnerabilities\", [])\n}\n\n/// 全重大度を取得し、正規化した行の配列を返す。{ rows, anyOk }\n@fetchAll() {\n let now = Date:now()\n let endEnc = isoParam(now)\n let startEnc = isoParam(now - DAYS * 86400000)\n let sevs = SEVERITIES.replace(\" \", \"\").split(\",\")\n\n var rows = []\n var anyOk = false\n for (let i, sevs.len) {\n let sev = sevs[i]\n if (sev != \"\") {\n let vulns = fetchSeverity(sev, startEnc, endEnc)\n if (Core:type(vulns) == \"arr\") {\n anyOk = true\n for (let j, vulns.len) { rows.push(normalize(vulns[j])) }\n }\n }\n }\n { rows: rows, anyOk: anyOk }\n}\n\n/// modKey の降順 (新しい順) に選択ソートし、上位 MAX_ITEMS を返す\n@topByRecent(rows) {\n var a = []\n for (let i, rows.len) { a.push(rows[i]) }\n let n = a.len\n for (let i, n) {\n var maxIdx = i\n for (let k, n) {\n if (k > i && a[k].modKey > a[maxIdx].modKey) { maxIdx = k }\n }\n if (maxIdx != i) {\n let t = a[i]\n a[i] = a[maxIdx]\n a[maxIdx] = t\n }\n }\n let m = if (n > MAX_ITEMS) { MAX_ITEMS } else { n }\n var picked = []\n for (let i, m) { picked.push(a[i]) }\n picked\n}\n\n// ─────────────────────────────\n// UI 構築\n// ─────────────────────────────\n/// 1 CVE = 2 行カード\n/// 行1: ● CVE-ID …………… スコア 重大度\n/// 行2: MM/DD 説明文\n@cveCard(e) {\n let col = sevColor(e.sev)\n\n // CVE-ID は NVD の詳細ページへのリンクにする (MFM リンクは独自スタイルのため色/太字は付けない)\n let l1Plain = `● {e.id}`\n let l1Show = `$[fg.color={col} ●] [{e.id}](https://nvd.nist.gov/vuln/detail/{e.id})`\n let rPlain = `{scoreStr(e.score)} {sevShort(e.sev)}`\n let rShow = `$[fg.color={col} **{scoreStr(e.score)}** {sevShort(e.sev)}]`\n let line1 = justify(l1Plain, l1Show, rPlain, rShow, WIDTH)\n\n let md = monthDay(e.pub)\n let line2 = if (md != \"\") {\n ` $[fg.color={COL_DIM} {md}] $[fg.color={COL_DESC} {e.desc}]`\n } else {\n ` $[fg.color={COL_DESC} {e.desc}]`\n }\n\n [\n Ui:C:mfm({ text: line1, font: \"monospace\" })\n Ui:C:mfm({ text: line2, font: \"monospace\" })\n ]\n}\n\n@cveView(rows) {\n let now = Date:now()\n var body = [\n Ui:C:mfm({ text: `**🛡 CVE 脆弱性** $[fg.color={COL_DIM} 更新 {pad2(Date:hour(now))}:{pad2(Date:minute(now))}]` })\n Ui:C:mfm({ text: `$[fg.color={COL_DIM} 直近 {DAYS} 日 / {SEVERITIES}]` })\n ]\n for (let i, rows.len) {\n body = body.concat(cveCard(rows[i]))\n }\n body\n}\n\nvar firstRun = true\nvar hasData = false // 一度でも一覧表示に成功したか\n\n@render() {\n if (firstRun) {\n Ui:render([\n Ui:C:container({\n align: \"center\"\n children: [Ui:C:text({ text: \"CVE を読み込み中…\" })]\n })\n ])\n }\n firstRun = false\n\n let res = fetchAll()\n\n // 全リクエストが失敗した場合のみエラー扱い\n if (!res.anyOk) {\n if (hasData) { return null } // 既存表示を維持してチラつき防止\n let hint = if (lastStatus == 403 || lastStatus == 429) {\n \"レート制限中。しばらく待つと回復します\"\n } else {\n \"通信エラー\"\n }\n Ui:render([\n Ui:C:container({\n align: \"center\"\n children: [\n Ui:C:mfm({ text: `$[fg.color=ea3943 ⚠️ CVE データを取得できません (HTTP {lastStatus})]` })\n Ui:C:mfm({ text: `$[fg.color={COL_FLAT} {hint}]` })\n ]\n })\n ])\n return null\n }\n\n let rows = topByRecent(res.rows)\n if (rows.len == 0) {\n Ui:render([\n Ui:C:container({\n align: \"center\"\n children: [\n Ui:C:mfm({ text: `$[fg.color={COL_FLAT} 直近 {DAYS} 日に該当する CVE はありません]` })\n ]\n })\n ])\n return null\n }\n hasData = true\n\n Ui:render([Ui:C:container({ align: \"center\", children: cveView(rows) })])\n}\n\n// 自動更新は行わない (NVD keyless のレート制限回避)。\n// 最新化したいときはウィジェットを再読み込みする。\nrender()\n"}
+{"type":"widget","data":"/// @ 1.2.1\n/// CVE 脆弱性ウィジェット\n/// NVD (NIST National Vulnerability Database) から直近の重大な脆弱性 (CVE) を\n/// CVSS スコア・重大度付きで一覧表示する。API キー不要 (公開 API を直接叩く)。\n///\n/// NVD のデフォルトの並びは古い CVE から (CVE-1999-... 等) なので、\n/// lastModStartDate/lastModEndDate で「直近 N 日に更新された CVE」に絞り込み、\n/// クライアント側で lastModified の新しい順にソートして上位を表示する。\n///\n/// 設定は下の定数を直接編集する:\n/// SEVERITIES : 取得する重大度 (カンマ区切り)。CRITICAL,HIGH,MEDIUM,LOW\n/// DAYS : 何日前まで遡るか (NVD の制限上 最大 120)\n/// MAX_ITEMS : 表示する最大件数\n///\n/// 認証なしのレート制限は 30 秒あたり 5 リクエスト。重大度の数だけ叩くので\n/// SEVERITIES は 1〜3 個程度に留めること。\n\nlet SEVERITIES = \"CRITICAL,HIGH\" // 取得する重大度 (カンマ区切り)\nlet DAYS = 7 // 直近何日ぶんを対象にするか\nlet MAX_ITEMS = 6 // 表示する最大件数\nlet PER_SEV = 6 // 重大度ごとの取得件数 (NVD への 1 リクエスト分。大きいと応答が肥大化)\nlet WIDTH = 34 // monospace 整列の桁幅 (半角換算)\nlet DESC_LEN = 60 // 説明文の最大表示文字数\n\nvar lastStatus = 0 // 直近の HTTP ステータス (エラー表示用)\n/// Nd:http の失敗理由。status 0 は通信断 (DNS 失敗・接続拒否・タイムアウト) で、\n/// NoteDeck は throw せず値で返す (notedeck#1294)\n@httpLabel(st) { if (st == 0) { \"通信できません\" } else { `HTTP {st}` } }\n\n// stocks/crypto ウィジェットと揃えたカラー\nlet COL_DIM = \"5b6470\"\nlet COL_FLAT = \"8a919e\"\nlet COL_DESC = \"b0b7c3\"\n\n// ─────────────────────────────\n// ヘルパー\n// ─────────────────────────────\n@dflt(v, d) { if (Core:type(v) == \"null\") d else v }\n\n@get(o, k, d) {\n if (Core:type(o) != \"obj\") { d }\n elif (Obj:has(o, k)) { dflt(o[k], d) }\n else { d }\n}\n\n@digitVal(c) {\n if (c == \"0\") { 0 } elif (c == \"1\") { 1 } elif (c == \"2\") { 2 }\n elif (c == \"3\") { 3 } elif (c == \"4\") { 4 } elif (c == \"5\") { 5 }\n elif (c == \"6\") { 6 } elif (c == \"7\") { 7 } elif (c == \"8\") { 8 }\n elif (c == \"9\") { 9 } else { -1 }\n}\n\n/// 数字のみの文字列を数値へ。失敗時 0。\n@strToNum(s) {\n if (Core:type(s) != \"str\" || s == \"\") { return 0 }\n var n = 0\n for (let i, s.len) {\n let d = digitVal(s.slice(i, i + 1))\n if (d < 0) { return 0 }\n n = n * 10 + d\n }\n n\n}\n\n@pad2(n) { if (n < 10) { `0{n}` } else { `{n}` } }\n\n/// 同じ文字を n 個並べた文字列\n@repeatStr(ch, n) {\n var s = \"\"\n if (n > 0) { for (let i, n) { s = `{s}{ch}` } }\n s\n}\n\n/// 左セグメントと右セグメントを幅 w に揃える (間をスペース埋め)\n@justify(lPlain, lShow, rPlain, rShow, w) {\n let gap = w - lPlain.len - rPlain.len\n `{lShow}{repeatStr(\" \", if (gap > 0) { gap } else { 1 })}{rShow}`\n}\n\n/// ISO 8601 タイムスタンプ (\"2026-06-16T21:48:22.267\") → 比較用の数値キー。失敗時 0。\n@tsKey(s) {\n if (Core:type(s) != \"str\" || s.len < 16) { return 0 }\n let y = strToNum(s.slice(0, 4))\n let mo = strToNum(s.slice(5, 7))\n let d = strToNum(s.slice(8, 10))\n let h = strToNum(s.slice(11, 13))\n let mi = strToNum(s.slice(14, 16))\n (((y * 100 + mo) * 100 + d) * 100 + h) * 100 + mi\n}\n\n/// ISO 8601 文字列の MM/DD 部分\n@monthDay(s) {\n if (Core:type(s) != \"str\" || s.len < 10) { \"\" }\n else { `{s.slice(5, 7)}/{s.slice(8, 10)}` }\n}\n\n/// NVD 日付パラメータ用に timestamp を ISO 8601 (コロンは %3A エンコード) へ\n@isoParam(t) {\n `{Date:year(t)}-{pad2(Date:month(t))}-{pad2(Date:day(t))}T{pad2(Date:hour(t))}%3A{pad2(Date:minute(t))}%3A00.000`\n}\n\n/// MFM の記号を無害化し改行を畳んだうえで切り詰める\n@cleanDesc(s) {\n if (Core:type(s) != \"str\") { return \"\" }\n var t = s\n t = t.replace(\"[\", \"(\")\n t = t.replace(\"]\", \")\")\n t = t.replace(\"`\", \"'\")\n t = t.replace(\"*\", \"・\")\n t = t.replace(\"@\", \"@\")\n t = t.replace(\"#\", \"#\")\n t = t.replace(\"$\", \"$\")\n if (t.len > DESC_LEN) { `{t.slice(0, DESC_LEN)}…` } else { t }\n}\n\n@sevColor(sev) {\n if (sev == \"CRITICAL\") { \"ea3943\" }\n elif (sev == \"HIGH\") { \"ff8c42\" }\n elif (sev == \"MEDIUM\") { \"f5a623\" }\n elif (sev == \"LOW\") { \"16c784\" }\n else { COL_FLAT }\n}\n\n@sevShort(sev) {\n if (sev == \"CRITICAL\") { \"CRIT\" }\n elif (sev == \"HIGH\") { \"HIGH\" }\n elif (sev == \"MEDIUM\") { \"MED\" }\n elif (sev == \"LOW\") { \"LOW\" }\n else { \"?\" }\n}\n\n@scoreStr(score) {\n if (Core:type(score) == \"null\") { \"-\" } else { `{score}` }\n}\n\n// ─────────────────────────────\n// データ取得・整形\n// ─────────────────────────────\n/// metrics から CVSS v3.1 → v3.0 の順で cvssData を取り出す\n@cvssData(m) {\n let v31 = get(m, \"cvssMetricV31\", [])\n if (Core:type(v31) == \"arr\" && v31.len > 0) { return get(v31[0], \"cvssData\", {}) }\n let v30 = get(m, \"cvssMetricV30\", [])\n if (Core:type(v30) == \"arr\" && v30.len > 0) { return get(v30[0], \"cvssData\", {}) }\n {}\n}\n\n/// cve オブジェクトから英語の説明文を取り出す\n@descOf(cve) {\n let ds = get(cve, \"descriptions\", [])\n if (Core:type(ds) != \"arr\") { return \"\" }\n for (let i, ds.len) {\n if (get(ds[i], \"lang\", \"\") == \"en\") { return get(ds[i], \"value\", \"\") }\n }\n if (ds.len > 0) { get(ds[0], \"value\", \"\") } else { \"\" }\n}\n\n/// NVD の vulnerabilities 要素 → 表示用の正規化オブジェクト\n@normalize(item) {\n let cve = get(item, \"cve\", {})\n let cd = cvssData(get(cve, \"metrics\", {}))\n let mod = get(cve, \"lastModified\", \"\")\n {\n id: get(cve, \"id\", \"?\")\n score: get(cd, \"baseScore\", null)\n sev: get(cd, \"baseSeverity\", \"\")\n pub: get(cve, \"published\", \"\")\n modKey: tsKey(mod)\n desc: cleanDesc(descOf(cve))\n }\n}\n\n/// 1 重大度ぶんを取得。vulnerabilities 配列を返す。失敗時 null。\n@fetchSeverity(sev, startEnc, endEnc) {\n let url = `https://services.nvd.nist.gov/rest/json/cves/2.0?cvssV3Severity={sev}&lastModStartDate={startEnc}&lastModEndDate={endEnc}&resultsPerPage={PER_SEV}`\n // Accept-Encoding: identity で gzip を明示的に拒否する。\n // NVD は gzip 応答を返すことがあり、ランタイムの自動展開に失敗して\n // \"error decoding response body\" となるため、非圧縮で受け取る。\n let res = Nd:http(url, {\n method: \"GET\"\n headers: {\n \"Accept\": \"application/json\"\n \"Accept-Encoding\": \"identity\"\n }\n })\n lastStatus = res.status\n if (res.status != 200) { return null }\n let doc = Json:parse(res.body)\n get(doc, \"vulnerabilities\", [])\n}\n\n/// 全重大度を取得し、正規化した行の配列を返す。{ rows, anyOk }\n@fetchAll() {\n let now = Date:now()\n let endEnc = isoParam(now)\n let startEnc = isoParam(now - DAYS * 86400000)\n let sevs = SEVERITIES.replace(\" \", \"\").split(\",\")\n\n var rows = []\n var anyOk = false\n for (let i, sevs.len) {\n let sev = sevs[i]\n if (sev != \"\") {\n let vulns = fetchSeverity(sev, startEnc, endEnc)\n if (Core:type(vulns) == \"arr\") {\n anyOk = true\n for (let j, vulns.len) { rows.push(normalize(vulns[j])) }\n }\n }\n }\n { rows: rows, anyOk: anyOk }\n}\n\n/// modKey の降順 (新しい順) に選択ソートし、上位 MAX_ITEMS を返す\n@topByRecent(rows) {\n var a = []\n for (let i, rows.len) { a.push(rows[i]) }\n let n = a.len\n for (let i, n) {\n var maxIdx = i\n for (let k, n) {\n if (k > i && a[k].modKey > a[maxIdx].modKey) { maxIdx = k }\n }\n if (maxIdx != i) {\n let t = a[i]\n a[i] = a[maxIdx]\n a[maxIdx] = t\n }\n }\n let m = if (n > MAX_ITEMS) { MAX_ITEMS } else { n }\n var picked = []\n for (let i, m) { picked.push(a[i]) }\n picked\n}\n\n// ─────────────────────────────\n// UI 構築\n// ─────────────────────────────\n/// 1 CVE = 2 行カード\n/// 行1: ● CVE-ID …………… スコア 重大度\n/// 行2: MM/DD 説明文\n@cveCard(e) {\n let col = sevColor(e.sev)\n\n // CVE-ID は NVD の詳細ページへのリンクにする (MFM リンクは独自スタイルのため色/太字は付けない)\n let l1Plain = `● {e.id}`\n let l1Show = `$[fg.color={col} ●] [{e.id}](https://nvd.nist.gov/vuln/detail/{e.id})`\n let rPlain = `{scoreStr(e.score)} {sevShort(e.sev)}`\n let rShow = `$[fg.color={col} **{scoreStr(e.score)}** {sevShort(e.sev)}]`\n let line1 = justify(l1Plain, l1Show, rPlain, rShow, WIDTH)\n\n let md = monthDay(e.pub)\n let line2 = if (md != \"\") {\n ` $[fg.color={COL_DIM} {md}] $[fg.color={COL_DESC} {e.desc}]`\n } else {\n ` $[fg.color={COL_DESC} {e.desc}]`\n }\n\n [\n Ui:C:mfm({ text: line1, font: \"monospace\" })\n Ui:C:mfm({ text: line2, font: \"monospace\" })\n ]\n}\n\n@cveView(rows) {\n let now = Date:now()\n var body = [\n Ui:C:mfm({ text: `**🛡 CVE 脆弱性** $[fg.color={COL_DIM} 更新 {pad2(Date:hour(now))}:{pad2(Date:minute(now))}]` })\n Ui:C:mfm({ text: `$[fg.color={COL_DIM} 直近 {DAYS} 日 / {SEVERITIES}]` })\n ]\n for (let i, rows.len) {\n body = body.concat(cveCard(rows[i]))\n }\n body\n}\n\nvar firstRun = true\nvar hasData = false // 一度でも一覧表示に成功したか\n\n@render() {\n if (firstRun) {\n Ui:render([\n Ui:C:container({\n align: \"center\"\n children: [Ui:C:text({ text: \"CVE を読み込み中…\" })]\n })\n ])\n }\n firstRun = false\n\n let res = fetchAll()\n\n // 全リクエストが失敗した場合のみエラー扱い\n if (!res.anyOk) {\n if (hasData) { return null } // 既存表示を維持してチラつき防止\n let hint = if (lastStatus == 403 || lastStatus == 429) {\n \"レート制限中。しばらく待つと回復します\"\n } else {\n \"通信エラー\"\n }\n Ui:render([\n Ui:C:container({\n align: \"center\"\n children: [\n Ui:C:mfm({ text: `$[fg.color=ea3943 ⚠️ CVE データを取得できません ({httpLabel(lastStatus)})]` })\n Ui:C:mfm({ text: `$[fg.color={COL_FLAT} {hint}]` })\n ]\n })\n ])\n return null\n }\n\n let rows = topByRecent(res.rows)\n if (rows.len == 0) {\n Ui:render([\n Ui:C:container({\n align: \"center\"\n children: [\n Ui:C:mfm({ text: `$[fg.color={COL_FLAT} 直近 {DAYS} 日に該当する CVE はありません]` })\n ]\n })\n ])\n return null\n }\n hasData = true\n\n Ui:render([Ui:C:container({ align: \"center\", children: cveView(rows) })])\n}\n\n// 自動更新は行わない (NVD keyless のレート制限回避)。\n// 最新化したいときはウィジェットを再読み込みする。\nrender()\n"}
diff --git a/public/registry/widgets/cve/widget.is b/public/registry/widgets/cve/widget.is
index 28edfab..8747e42 100644
--- a/public/registry/widgets/cve/widget.is
+++ b/public/registry/widgets/cve/widget.is
@@ -23,6 +23,9 @@ let WIDTH = 34 // monospace 整列の桁幅 (半角換算)
let DESC_LEN = 60 // 説明文の最大表示文字数
var lastStatus = 0 // 直近の HTTP ステータス (エラー表示用)
+/// Nd:http の失敗理由。status 0 は通信断 (DNS 失敗・接続拒否・タイムアウト) で、
+/// NoteDeck は throw せず値で返す (notedeck#1294)
+@httpLabel(st) { if (st == 0) { "通信できません" } else { `HTTP {st}` } }
// stocks/crypto ウィジェットと揃えたカラー
let COL_DIM = "5b6470"
@@ -299,7 +302,7 @@ var hasData = false // 一度でも一覧表示に成功したか
Ui:C:container({
align: "center"
children: [
- Ui:C:mfm({ text: `$[fg.color=ea3943 ⚠️ CVE データを取得できません (HTTP {lastStatus})]` })
+ Ui:C:mfm({ text: `$[fg.color=ea3943 ⚠️ CVE データを取得できません ({httpLabel(lastStatus)})]` })
Ui:C:mfm({ text: `$[fg.color={COL_FLAT} {hint}]` })
]
})
diff --git a/public/registry/widgets/discord-online/api.json b/public/registry/widgets/discord-online/api.json
index d26ba76..063be4a 100644
--- a/public/registry/widgets/discord-online/api.json
+++ b/public/registry/widgets/discord-online/api.json
@@ -1 +1 @@
-{"type":"widget","data":"/// @ 1.2.1\n/// Discord オンライン状態ウィジェット\n/// Lanyard API (https://api.lanyard.rest/) で Discord ユーザーの\n/// オンライン状態・利用デバイス・アクティビティ (Spotify 等) を表示。API キー不要。\n/// API は NoteDeck サーバーサイド (Nd:http) で叩くため CORS/UA 制約を受けない。\n///\n/// 対象ユーザーは下の DEFAULT_USER_ID を Discord のユーザー ID に書き換える。\n/// 注意: Lanyard は対象ユーザーが Discord の Lanyard サーバー\n/// (https://discord.gg/lanyard) に参加している場合のみ presence を取得できる。\n/// 未参加のユーザーは表示できない (案内を表示)。\n\nlet DEFAULT_USER_ID = \"711585272197939230\"\n\nvar lastStatus = 0 // 直近の HTTP ステータス (エラー表示用)\n\n// カラーパレット\nlet COL_ONLINE = \"23a55a\" // online バッジ背景 (緑)\nlet COL_IDLE = \"f0b232\" // idle バッジ背景 (黄)\nlet COL_DND = \"f23f43\" // dnd バッジ背景 (赤)\nlet COL_OFF = \"80848e\" // offline バッジ背景 (灰)\nlet COL_BADGE_T = \"0b0d12\" // バッジ文字 (濃色・背景とのコントラスト用)\nlet COL_ACCENT = \"5865f2\" // 表示名 (Discord blurple)\nlet COL_TEXT = \"eff2f5\" // 主要テキスト\nlet COL_DIM = \"9aa4b0\" // ラベル・補助\nlet COL_SUB = \"6b7480\" // ユーザー名・フッター\n\n// ─────────────────────────────\n// ヘルパー\n// ─────────────────────────────\n@dflt(v, d) { if (Core:type(v) == \"null\") d else v }\n\n@get(o, k, d) {\n if (Core:type(o) != \"obj\") { d }\n elif (Obj:has(o, k)) { dflt(o[k], d) }\n else { d }\n}\n\n@pad2(n) { if (n < 10) { `0{n}` } else { `{n}` } }\n\n/// ピル型ステータスバッジ (背景色 + 前景濃色)\n@badge(bg, label) {\n `$[bg.color={bg} $[fg.color={COL_BADGE_T} {label} ]]`\n}\n\n/// discord_status を色・ラベル・アイコンに変換\n@statusInfo(s) {\n if (s == \"online\") { { col: COL_ONLINE, label: \"オンライン\", icon: \"●\" } }\n elif (s == \"idle\") { { col: COL_IDLE, label: \"退席中\", icon: \"🌙\" } }\n elif (s == \"dnd\") { { col: COL_DND, label: \"取り込み中\", icon: \"⛔\" } }\n elif (s == \"offline\") { { col: COL_OFF, label: \"オフライン\", icon: \"○\" } }\n else { { col: COL_OFF, label: \"不明\", icon: \"?\" } }\n}\n\n/// 利用中デバイスを「🖥️ PC 📱 Mobile」のように整形 (なければ \"\")\n@platformLine(data) {\n var parts = []\n if (get(data, \"active_on_discord_desktop\", false) == true) { parts.push(\"🖥️ PC\") }\n if (get(data, \"active_on_discord_web\", false) == true) { parts.push(\"🌐 Web\") }\n if (get(data, \"active_on_discord_mobile\", false) == true) { parts.push(\"📱 Mobile\") }\n parts.join(\" \")\n}\n\n/// 現在のアクティビティを 1 行に整形 (Spotify > カスタムステータス > プレイ中、なければ \"\")\n@activityLine(data) {\n // Spotify 再生中を最優先\n if (get(data, \"listening_to_spotify\", false) == true) {\n let sp = get(data, \"spotify\", {})\n return `$[fg.color={COL_TEXT} 🎵 {get(sp, \"song\", \"\")}]$[fg.color={COL_DIM} — {get(sp, \"artist\", \"\")}]`\n }\n let acts = get(data, \"activities\", [])\n var custom = \"\" // type 4: カスタムステータス\n var playing = \"\" // type 0: プレイ中ゲーム\n for (let i, acts.len) {\n let a = acts[i]\n let ty = get(a, \"type\", -1)\n if (ty == 4) {\n let stt = get(a, \"state\", \"\")\n let emo = get(get(a, \"emoji\", {}), \"name\", \"\")\n custom = if (emo != \"\") { `{emo} {stt}` } else { stt }\n } elif (ty == 0 && playing == \"\") {\n playing = get(a, \"name\", \"\")\n }\n }\n if (custom != \"\") { return `$[fg.color={COL_DIM} 💬 {custom}]` }\n if (playing != \"\") { return `$[fg.color={COL_DIM} 🎮 {playing}]` }\n \"\"\n}\n\n// ─────────────────────────────\n// データ取得\n// ─────────────────────────────\n@fetchPresence(id) {\n let url = `https://api.lanyard.rest/v1/users/{id}`\n let res = Nd:http(url, {\n method: \"GET\"\n headers: { \"Accept\": \"application/json\" }\n })\n lastStatus = res.status\n // 200=取得成功 / 404=未登録 (どちらも JSON ボディを持つ) のみパースする\n if (res.status != 200 && res.status != 404) { return null }\n Json:parse(res.body)\n}\n\n// ─────────────────────────────\n// UI 構築\n// ─────────────────────────────\n@statusView(data) {\n let now = Date:now()\n let clock = `{pad2(Date:hour(now))}:{pad2(Date:minute(now))}`\n\n let du = get(data, \"discord_user\", {})\n let nameStr = get(du, \"global_name\", get(du, \"username\", \"ユーザー\"))\n let uname = get(du, \"username\", \"\")\n let st = get(data, \"discord_status\", \"offline\")\n let info = statusInfo(st)\n\n // ヘッダー: 表示名 + ユーザー名\n var body = [\n Ui:C:mfm({ text: `$[fg.color={COL_ACCENT} **{nameStr}**]` })\n ]\n if (uname != \"\") {\n body.push(Ui:C:mfm({ text: `$[fg.color={COL_SUB} @{uname}]` }))\n }\n\n // ステータスピル\n body.push(Ui:C:mfm({ text: `{badge(info.col, `{info.icon} {info.label}`)}` }))\n\n // オンライン系のときだけデバイス・アクティビティを表示\n if (st != \"offline\") {\n let plat = platformLine(data)\n if (plat != \"\") {\n body.push(Ui:C:mfm({ text: `$[fg.color={COL_DIM} {plat}]` }))\n }\n let act = activityLine(data)\n if (act != \"\") {\n body.push(Ui:C:mfm({ text: `{act}` }))\n }\n }\n\n // フッター: 更新時刻\n body.push(Ui:C:mfm({ text: `$[fg.color={COL_SUB} 🕒 {clock}]` }))\n body\n}\n\nvar firstRun = true\nvar hasData = false // 一度でも表示に成功したか\n\n@render() {\n if (firstRun) {\n Ui:render([\n Ui:C:container({\n align: \"center\"\n children: [Ui:C:text({ text: \"Discord の状態を読み込み中…\" })]\n })\n ])\n }\n firstRun = false\n\n let data = fetchPresence(DEFAULT_USER_ID)\n\n // 通信失敗\n if (Core:type(data) != \"obj\") {\n if (hasData) { return null } // 既に表示があれば維持してチラつきを防ぐ\n Ui:render([\n Ui:C:container({\n align: \"center\"\n children: [\n Ui:C:mfm({ text: `$[fg.color=f23f43 ⚠️ Discord の状態を取得できません (HTTP {lastStatus})]` })\n ]\n })\n ])\n return null\n }\n\n // Lanyard 未登録 (success=false)。actionable な案内を表示。\n if (get(data, \"success\", false) != true) {\n Ui:render([\n Ui:C:container({\n align: \"center\"\n children: [\n Ui:C:mfm({ text: `$[fg.color={COL_DIM} 🔍 このユーザーは Lanyard に未登録です]` })\n Ui:C:mfm({ text: `$[fg.color={COL_SUB} discord.gg/lanyard に参加すると表示できます]` })\n ]\n })\n ])\n return null\n }\n hasData = true\n\n let children = statusView(data.data)\n Ui:render([Ui:C:container({ align: \"center\", children: children })])\n}\n\n// 1 更新 = 1 リクエスト。60 秒間隔 (hital.in の Discord ポーリングに合わせる)。\nrender()\nAsync:interval(60000, render, false)\n"}
+{"type":"widget","data":"/// @ 1.2.1\n/// Discord オンライン状態ウィジェット\n/// Lanyard API (https://api.lanyard.rest/) で Discord ユーザーの\n/// オンライン状態・利用デバイス・アクティビティ (Spotify 等) を表示。API キー不要。\n/// API は NoteDeck サーバーサイド (Nd:http) で叩くため CORS/UA 制約を受けない。\n///\n/// 対象ユーザーは下の DEFAULT_USER_ID を Discord のユーザー ID に書き換える。\n/// 注意: Lanyard は対象ユーザーが Discord の Lanyard サーバー\n/// (https://discord.gg/lanyard) に参加している場合のみ presence を取得できる。\n/// 未参加のユーザーは表示できない (案内を表示)。\n\nlet DEFAULT_USER_ID = \"711585272197939230\"\n\nvar lastStatus = 0 // 直近の HTTP ステータス (エラー表示用)\n/// Nd:http の失敗理由。status 0 は通信断 (DNS 失敗・接続拒否・タイムアウト) で、\n/// NoteDeck は throw せず値で返す (notedeck#1294)\n@httpLabel(st) { if (st == 0) { \"通信できません\" } else { `HTTP {st}` } }\n\n// カラーパレット\nlet COL_ONLINE = \"23a55a\" // online バッジ背景 (緑)\nlet COL_IDLE = \"f0b232\" // idle バッジ背景 (黄)\nlet COL_DND = \"f23f43\" // dnd バッジ背景 (赤)\nlet COL_OFF = \"80848e\" // offline バッジ背景 (灰)\nlet COL_BADGE_T = \"0b0d12\" // バッジ文字 (濃色・背景とのコントラスト用)\nlet COL_ACCENT = \"5865f2\" // 表示名 (Discord blurple)\nlet COL_TEXT = \"eff2f5\" // 主要テキスト\nlet COL_DIM = \"9aa4b0\" // ラベル・補助\nlet COL_SUB = \"6b7480\" // ユーザー名・フッター\n\n// ─────────────────────────────\n// ヘルパー\n// ─────────────────────────────\n@dflt(v, d) { if (Core:type(v) == \"null\") d else v }\n\n@get(o, k, d) {\n if (Core:type(o) != \"obj\") { d }\n elif (Obj:has(o, k)) { dflt(o[k], d) }\n else { d }\n}\n\n@pad2(n) { if (n < 10) { `0{n}` } else { `{n}` } }\n\n/// ピル型ステータスバッジ (背景色 + 前景濃色)\n@badge(bg, label) {\n `$[bg.color={bg} $[fg.color={COL_BADGE_T} {label} ]]`\n}\n\n/// discord_status を色・ラベル・アイコンに変換\n@statusInfo(s) {\n if (s == \"online\") { { col: COL_ONLINE, label: \"オンライン\", icon: \"●\" } }\n elif (s == \"idle\") { { col: COL_IDLE, label: \"退席中\", icon: \"🌙\" } }\n elif (s == \"dnd\") { { col: COL_DND, label: \"取り込み中\", icon: \"⛔\" } }\n elif (s == \"offline\") { { col: COL_OFF, label: \"オフライン\", icon: \"○\" } }\n else { { col: COL_OFF, label: \"不明\", icon: \"?\" } }\n}\n\n/// 利用中デバイスを「🖥️ PC 📱 Mobile」のように整形 (なければ \"\")\n@platformLine(data) {\n var parts = []\n if (get(data, \"active_on_discord_desktop\", false) == true) { parts.push(\"🖥️ PC\") }\n if (get(data, \"active_on_discord_web\", false) == true) { parts.push(\"🌐 Web\") }\n if (get(data, \"active_on_discord_mobile\", false) == true) { parts.push(\"📱 Mobile\") }\n parts.join(\" \")\n}\n\n/// 現在のアクティビティを 1 行に整形 (Spotify > カスタムステータス > プレイ中、なければ \"\")\n@activityLine(data) {\n // Spotify 再生中を最優先\n if (get(data, \"listening_to_spotify\", false) == true) {\n let sp = get(data, \"spotify\", {})\n return `$[fg.color={COL_TEXT} 🎵 {get(sp, \"song\", \"\")}]$[fg.color={COL_DIM} — {get(sp, \"artist\", \"\")}]`\n }\n let acts = get(data, \"activities\", [])\n var custom = \"\" // type 4: カスタムステータス\n var playing = \"\" // type 0: プレイ中ゲーム\n for (let i, acts.len) {\n let a = acts[i]\n let ty = get(a, \"type\", -1)\n if (ty == 4) {\n let stt = get(a, \"state\", \"\")\n let emo = get(get(a, \"emoji\", {}), \"name\", \"\")\n custom = if (emo != \"\") { `{emo} {stt}` } else { stt }\n } elif (ty == 0 && playing == \"\") {\n playing = get(a, \"name\", \"\")\n }\n }\n if (custom != \"\") { return `$[fg.color={COL_DIM} 💬 {custom}]` }\n if (playing != \"\") { return `$[fg.color={COL_DIM} 🎮 {playing}]` }\n \"\"\n}\n\n// ─────────────────────────────\n// データ取得\n// ─────────────────────────────\n@fetchPresence(id) {\n let url = `https://api.lanyard.rest/v1/users/{id}`\n let res = Nd:http(url, {\n method: \"GET\"\n headers: { \"Accept\": \"application/json\" }\n })\n lastStatus = res.status\n // 200=取得成功 / 404=未登録 (どちらも JSON ボディを持つ) のみパースする\n if (res.status != 200 && res.status != 404) { return null }\n Json:parse(res.body)\n}\n\n// ─────────────────────────────\n// UI 構築\n// ─────────────────────────────\n@statusView(data) {\n let now = Date:now()\n let clock = `{pad2(Date:hour(now))}:{pad2(Date:minute(now))}`\n\n let du = get(data, \"discord_user\", {})\n let nameStr = get(du, \"global_name\", get(du, \"username\", \"ユーザー\"))\n let uname = get(du, \"username\", \"\")\n let st = get(data, \"discord_status\", \"offline\")\n let info = statusInfo(st)\n\n // ヘッダー: 表示名 + ユーザー名\n var body = [\n Ui:C:mfm({ text: `$[fg.color={COL_ACCENT} **{nameStr}**]` })\n ]\n if (uname != \"\") {\n body.push(Ui:C:mfm({ text: `$[fg.color={COL_SUB} @{uname}]` }))\n }\n\n // ステータスピル\n body.push(Ui:C:mfm({ text: `{badge(info.col, `{info.icon} {info.label}`)}` }))\n\n // オンライン系のときだけデバイス・アクティビティを表示\n if (st != \"offline\") {\n let plat = platformLine(data)\n if (plat != \"\") {\n body.push(Ui:C:mfm({ text: `$[fg.color={COL_DIM} {plat}]` }))\n }\n let act = activityLine(data)\n if (act != \"\") {\n body.push(Ui:C:mfm({ text: `{act}` }))\n }\n }\n\n // フッター: 更新時刻\n body.push(Ui:C:mfm({ text: `$[fg.color={COL_SUB} 🕒 {clock}]` }))\n body\n}\n\nvar firstRun = true\nvar hasData = false // 一度でも表示に成功したか\n\n@render() {\n if (firstRun) {\n Ui:render([\n Ui:C:container({\n align: \"center\"\n children: [Ui:C:text({ text: \"Discord の状態を読み込み中…\" })]\n })\n ])\n }\n firstRun = false\n\n let data = fetchPresence(DEFAULT_USER_ID)\n\n // 通信失敗\n if (Core:type(data) != \"obj\") {\n if (hasData) { return null } // 既に表示があれば維持してチラつきを防ぐ\n Ui:render([\n Ui:C:container({\n align: \"center\"\n children: [\n Ui:C:mfm({ text: `$[fg.color=f23f43 ⚠️ Discord の状態を取得できません ({httpLabel(lastStatus)})]` })\n ]\n })\n ])\n return null\n }\n\n // Lanyard 未登録 (success=false)。actionable な案内を表示。\n if (get(data, \"success\", false) != true) {\n Ui:render([\n Ui:C:container({\n align: \"center\"\n children: [\n Ui:C:mfm({ text: `$[fg.color={COL_DIM} 🔍 このユーザーは Lanyard に未登録です]` })\n Ui:C:mfm({ text: `$[fg.color={COL_SUB} discord.gg/lanyard に参加すると表示できます]` })\n ]\n })\n ])\n return null\n }\n hasData = true\n\n let children = statusView(data.data)\n Ui:render([Ui:C:container({ align: \"center\", children: children })])\n}\n\n// 1 更新 = 1 リクエスト。60 秒間隔 (hital.in の Discord ポーリングに合わせる)。\nrender()\nAsync:interval(60000, render, false)\n"}
diff --git a/public/registry/widgets/discord-online/widget.is b/public/registry/widgets/discord-online/widget.is
index f588bbc..a59b676 100644
--- a/public/registry/widgets/discord-online/widget.is
+++ b/public/registry/widgets/discord-online/widget.is
@@ -12,6 +12,9 @@
let DEFAULT_USER_ID = "711585272197939230"
var lastStatus = 0 // 直近の HTTP ステータス (エラー表示用)
+/// Nd:http の失敗理由。status 0 は通信断 (DNS 失敗・接続拒否・タイムアウト) で、
+/// NoteDeck は throw せず値で返す (notedeck#1294)
+@httpLabel(st) { if (st == 0) { "通信できません" } else { `HTTP {st}` } }
// カラーパレット
let COL_ONLINE = "23a55a" // online バッジ背景 (緑)
@@ -165,7 +168,7 @@ var hasData = false // 一度でも表示に成功したか
Ui:C:container({
align: "center"
children: [
- Ui:C:mfm({ text: `$[fg.color=f23f43 ⚠️ Discord の状態を取得できません (HTTP {lastStatus})]` })
+ Ui:C:mfm({ text: `$[fg.color=f23f43 ⚠️ Discord の状態を取得できません ({httpLabel(lastStatus)})]` })
]
})
])
diff --git a/public/registry/widgets/discord-presence/api.json b/public/registry/widgets/discord-presence/api.json
index db30c38..beb5ec2 100644
--- a/public/registry/widgets/discord-presence/api.json
+++ b/public/registry/widgets/discord-presence/api.json
@@ -1 +1 @@
-{"type":"widget","data":"/// @ 1.2.1\n/// Discord プレゼンス ウィジェット (Lanyard + サーバーウィジェット)\n/// 自分 (または指定ユーザー) の Discord の状態と Rich Presence (ゲーム名・モード・進行状況・\n/// 経過時間・パーティ人数・マップ / ランク等のツールチップ) を表示し、ゲーム中はワンタップで\n/// 「やってる」ノートにできる。共通サーバーの ID を入れると、そのサーバーで今ゲーム中の\n/// メンバーをゲーム別に一覧する (「今フレンドが何をしているか」)。API キー不要。\n///\n/// ■ データ源\n/// - 本人: Lanyard (https://api.lanyard.rest/v1/users/)。対象ユーザーが\n/// Discord の Lanyard サーバー (https://discord.gg/lanyard) に参加している必要がある\n/// - フレンド: Discord 公式のサーバーウィジェット (discord.com/api/guilds//widget.json)。\n/// サーバー設定 → ウィジェット を有効化してあるサーバーのみ。オンライン最大 100 人・表示名のみ\n///\n/// ■ 事前準備\n/// 1. Discord の設定 → 詳細設定 → 開発者モードを ON にし、自分を右クリック →「ユーザー ID をコピー」\n/// 2. 設定 (⚙️) にユーザー ID を貼る。フレンドも見たいならサーバーを右クリック →「サーバー ID をコピー」も貼る\n///\n/// 設計の元ネタ: discord-online (Lanyard) / nicolive-watch (設定 UI + 投稿ボタン)。\n/// 投稿ボタンはゲーム中だけ (Spotify 再生中は listenbrainz の #nowplaying と重なるので出さない)。\n\n// ─────────────────────────────\n// ヘルパー\n// ─────────────────────────────\n@dflt(v, d) { if (Core:type(v) == \"null\") d else v }\n\n@get(o, k, d) {\n if (Core:type(o) != \"obj\") { d }\n elif (Obj:has(o, k)) { dflt(o[k], d) }\n else { d }\n}\n\n@trim(s) {\n if (Core:type(s) != \"str\") { return \"\" }\n var a = 0\n var b = s.len\n loop { if (a < b && s.slice(a, a + 1) == \" \") { a = a + 1 } else { break } }\n loop { if (b > a && s.slice(b - 1, b) == \" \") { b = b - 1 } else { break } }\n s.slice(a, b)\n}\n\nlet DIGITS = \"0123456789\".split(\"\")\n/// Discord の snowflake ID か (数字のみ・17〜20 桁)。URL パスへのブレイクアウトを防ぐ\n@isSnowflake(s) {\n if (Core:type(s) != \"str\" || s.len < 17 || s.len > 20) { return false }\n var ok = true\n for (let i, s.len) { if (!DIGITS.incl(s.slice(i, i + 1))) { ok = false } }\n ok\n}\n\n/// MFM/HTML 制御文字を無害化。表示名・ゲーム名・サーバー名は外部由来なのでサニタイズ\n@sanitizeText(s) {\n if (Core:type(s) != \"str\") { return \"\" }\n var txt = s.replace(\"$[\", \"$ \")\n txt = txt.replace(\"]\", \"]\")\n txt = txt.replace(\"[\", \"[\")\n txt = txt.replace(\"<\", \"<\")\n txt = txt.replace(\">\", \">\")\n txt = txt.replace(\"`\", \"`\")\n txt = txt.replace(\"*\", \"*\")\n txt = txt.replace(\"\\\\\", \"\\")\n txt = txt.replace(\"@\", \"@\")\n txt = txt.replace(\"#\", \"#\")\n txt = txt.replace(\"://\", \"://\")\n txt\n}\n\n@truncName(s, n) {\n let clean = sanitizeText(s)\n if (clean.len > n) { `{clean.slice(0, n)}…` } else { clean }\n}\n\n@pad2(n) { if (n < 10) { `0{n}` } else { `{n}` } }\n\n/// 経過時間 \"30 分\" / \"2 時間 05 分\"\n@elapsed(startMs) {\n let mins = Math:floor((Date:now() - startMs) / 60000)\n if (mins < 1) { \"1 分未満\" }\n elif (mins < 60) { `{mins} 分` }\n else { `{Math:floor(mins / 60)} 時間 {pad2(mins % 60)} 分` }\n}\n\n// ─────────────────────────────\n// 定数\n// ─────────────────────────────\nlet REFRESH_MS = 60000 // Lanyard は 1 更新 1 リクエスト。hital.in と同じ 60 秒\nlet MAX_GAMES = 6 // サーバー一覧に出すゲーム数\nlet MAX_NAMES = 6 // ゲームごとに出す名前の数\n\nlet ACCENT = \"5865f2\" // Discord blurple\nlet MUTED = \"8a96a3\"\nlet LINE = \"5a6573\"\nlet GREEN = \"3dd68c\"\nlet YELLOW = \"ffb300\"\nlet RED = \"ff6961\"\nlet BADGE_T = \"0b0d12\" // チップ文字 (濃色)\nlet CHIP_BG = \"2f3a4a\" // 補足チップ (マップ / ランク) の背景\nlet SPOTIFY = \"1db954\"\n\n/// discord_status → { col, label, icon }\n@statusInfo(s) {\n if (s == \"online\") { { col: GREEN, label: \"オンライン\", icon: \"●\" } }\n elif (s == \"idle\") { { col: YELLOW, label: \"退席中\", icon: \"🌙\" } }\n elif (s == \"dnd\") { { col: RED, label: \"取り込み中\", icon: \"⛔\" } }\n elif (s == \"offline\") { { col: LINE, label: \"オフライン\", icon: \"○\" } }\n else { { col: LINE, label: \"不明\", icon: \"?\" } }\n}\n\n/// 角丸チップ。chunithm-rating / osu-stats と同じ `** X **` 詰め\n@chip(bg, fg, label) { `$[border.radius=4 $[bg.color={bg} $[fg.color={fg} ** {label} **]]]` }\n\n/// 文字の繰り返し (str に repeat は無い)\n@rep(ch, n) {\n var s = \"\"\n for (n) { s = `{s}{ch}` }\n s\n}\n\n/// パーティ人数バー \"▰▰▰▱▱\" (等幅)。6 人を超える party は数字だけ\n@partyBar(cur, max) {\n if (max <= 0 || max > 6) { return \"\" }\n let filled = Math:min(max, Math:max(0, cur))\n `$[font.monospace $[fg.color={ACCENT} {rep(\"▰\", filled)}]$[fg.color={LINE} {rep(\"▱\", max - filled)}]]`\n}\n\n/// activity.type → 絵文字 (0 プレイ中 / 1 配信中 / 2 再生中 / 3 視聴中 / 5 競技中)\n@actIcon(ty) {\n if (ty == 0) { \"🎮\" } elif (ty == 1) { \"📡\" } elif (ty == 2) { \"🎵\" } elif (ty == 3) { \"📺\" } elif (ty == 5) { \"🏆\" } else { \"✨\" }\n}\n\n// ─────────────────────────────\n// 状態\n// ─────────────────────────────\nvar userId = trim(dflt(Mk:load(\"dpUserId\"), \"\"))\nvar guildId = trim(dflt(Mk:load(\"dpGuildId\"), \"\"))\nvar result = null // null = 未取得 / { error } / { unregistered: true } / { me, guild }\nvar hasData = false // 一度でも表示に成功したか (通信失敗時のチラつき防止)\nvar lastStatus = 0\nvar firstRun = true\n\n// ─────────────────────────────\n// データ取得\n// ─────────────────────────────\n@httpGet(url) {\n Nd:http(url, { method: \"GET\", headers: { \"Accept\": \"application/json\" } })\n}\n\n/// Lanyard の data → 表示用 { name, uname, status, platforms, custom, spotify, acts: [{icon,name,details,state,party,since,large,small,playing}] }\n@parsePresence(d) {\n let du = get(d, \"discord_user\", {})\n let uname = get(du, \"username\", \"\")\n var platforms = []\n if (get(d, \"active_on_discord_desktop\", false) == true) { platforms.push(\"🖥️ PC\") }\n if (get(d, \"active_on_discord_web\", false) == true) { platforms.push(\"🌐 Web\") }\n if (get(d, \"active_on_discord_mobile\", false) == true) { platforms.push(\"📱 Mobile\") }\n var custom = \"\"\n var acts = []\n each (let a, get(d, \"activities\", [])) {\n let ty = get(a, \"type\", -1)\n if (ty == 4) {\n let emo = get(get(a, \"emoji\", {}), \"name\", \"\")\n let stt = get(a, \"state\", \"\")\n custom = trim(`{emo} {stt}`)\n } elif (ty != 2 || get(d, \"listening_to_spotify\", false) != true) {\n let assets = get(a, \"assets\", {})\n let party = get(get(a, \"party\", {}), \"size\", [])\n let start = get(get(a, \"timestamps\", {}), \"start\", null)\n acts.push({\n icon: actIcon(ty)\n playing: ty == 0\n name: get(a, \"name\", \"\")\n details: get(a, \"details\", \"\")\n state: get(a, \"state\", \"\")\n party: if (Core:type(party) == \"arr\" && party.len == 2) { `{party[0]}/{party[1]}` } else { \"\" }\n partyCur: if (Core:type(party) == \"arr\" && party.len == 2 && Core:type(party[0]) == \"num\") { party[0] } else { 0 }\n partyMax: if (Core:type(party) == \"arr\" && party.len == 2 && Core:type(party[1]) == \"num\") { party[1] } else { 0 }\n since: if (Core:type(start) == \"num\" && start > 0) { elapsed(start) } else { \"\" }\n large: get(assets, \"large_text\", \"\")\n small: get(assets, \"small_text\", \"\")\n })\n }\n }\n var spotify = null\n if (get(d, \"listening_to_spotify\", false) == true) {\n let sp = get(d, \"spotify\", {})\n spotify = { song: get(sp, \"song\", \"\"), artist: get(sp, \"artist\", \"\") }\n }\n {\n name: get(du, \"global_name\", uname)\n uname: uname\n status: get(d, \"discord_status\", \"offline\")\n platforms: platforms.join(\" \")\n custom: custom\n spotify: spotify\n acts: acts\n }\n}\n\n/// widget.json → { name, online, games: [{ name, members: [] }] } (自分は除外、ゲーム無しは数えない)\n/// username は「表示名または検閲済みアカウント名」なので、自分は両方の名前で除外する (サーバー別ニックネームは一致しない)\n@parseGuild(g, myNames) {\n var byGame = {}\n var order = []\n each (let m, get(g, \"members\", [])) {\n let game = get(get(m, \"game\", {}), \"name\", \"\")\n let who = get(m, \"username\", \"\")\n if (game != \"\" && who != \"\" && !myNames.incl(who)) {\n if (!Obj:has(byGame, game)) { Obj:set(byGame, game, []); order.push(game) }\n byGame[game].push(who)\n }\n }\n var games = []\n each (let gname, order) { games.push({ name: gname, members: byGame[gname] }) }\n games = games.sort(@(a, b) { b.members.len - a.members.len })\n { name: get(g, \"name\", \"\"), online: get(g, \"presence_count\", 0), games: games }\n}\n\n@fetchGuild(myNames) {\n let res = httpGet(`https://discord.com/api/guilds/{guildId}/widget.json`)\n if (res.status == 403) { return { error: \"サーバーのウィジェットが無効です (サーバー設定 → ウィジェット で有効化)\" } }\n if (res.status == 404) { return { error: \"サーバーが見つかりません (サーバー ID を確認)\" } }\n if (res.status != 200) { return { error: `サーバー情報を取得できません (HTTP {res.status})` } }\n let g = Json:parse(res.body)\n if (Core:type(g) != \"obj\") { return { error: \"サーバー情報を読めません\" } }\n parseGuild(g, myNames)\n}\n\n@fetchData() {\n if (!isSnowflake(userId)) {\n result = null\n render()\n return null\n }\n let res = httpGet(`https://api.lanyard.rest/v1/users/{userId}`)\n lastStatus = res.status\n // 200 = 取得成功 / 404 = Lanyard 未登録 (どちらも JSON)。それ以外は通信失敗扱い\n if (res.status != 200 && res.status != 404) {\n if (!hasData) { result = { error: `Discord の状態を取得できません (HTTP {res.status})` }; render() }\n return null\n }\n let body = Json:parse(res.body)\n if (Core:type(body) != \"obj\") {\n if (!hasData) { result = { error: \"Discord の状態を読めません\" }; render() }\n return null\n }\n if (get(body, \"success\", false) != true) {\n result = { unregistered: true }\n render()\n return null\n }\n let me = parsePresence(get(body, \"data\", {}))\n var guild = null\n if (isSnowflake(guildId)) { guild = fetchGuild([me.uname, me.name]) }\n result = { me: me, guild: guild, fetchedAt: Date:now() }\n hasData = true\n render()\n}\n\n// ─────────────────────────────\n// UI 構築 (様式: steam / chunithm-rating / mewk。見出し small → 主役 x2 → 補足 small)\n// ─────────────────────────────\n@divider() { Ui:C:mfm({ text: `$[fg.color={LINE} ───────────────]` }) }\n\n@timeLabel(ms) { `{pad2(Date:hour(ms))}:{pad2(Date:minute(ms))}` }\n\n@configFolder(opened) {\n Ui:C:folder({\n title: \"⚙️ 設定\"\n opened: opened\n children: [\n Ui:C:textInput({\n // 入力ごとに fetch すると再描画でフォーカスが飛ぶので保存のみ。反映は「適用」で\n // ID を変えたら前のユーザーの表示を維持しない (次の取得が失敗したらエラーを出す)\n onInput: @(v) { userId = trim(v); hasData = false; Mk:save(\"dpUserId\", userId) }\n default: userId\n label: \"Discord ユーザー ID\"\n caption: \"開発者モード ON → 自分を右クリック → ユーザー ID をコピー。Lanyard サーバー参加が必要\"\n })\n Ui:C:textInput({\n onInput: @(v) { guildId = trim(v); Mk:save(\"dpGuildId\", guildId) }\n default: guildId\n label: \"サーバー ID (任意)\"\n caption: \"ウィジェットを有効化したサーバーの ID。今ゲーム中のメンバーを一覧する\"\n })\n Ui:C:button({ text: \"✅ 適用して再読み込み\", rounded: true, onClick: @() { fetchData() } })\n ]\n })\n}\n\n/// 投稿本文。進行状況 (state) にはスコアや相手が入るので入れない\n@noteText(a) {\n var txt = `{a.icon} {a.name} やってる`\n if (a.details != \"\") { txt = `{txt} ({a.details})` }\n if (a.large != \"\" && a.large != a.details) { txt = `{txt} · {a.large}` }\n txt\n}\n\n/// 主役のアクティビティ: x2 のゲーム名 → 補足 (モード · 進行 · ⏱) → パーティバー → マップ / ランクのチップ\n@heroActivity(a) {\n var lines = [ Ui:C:mfm({ text: `$[x2 $[fg.color={ACCENT} **{a.icon} {truncName(a.name, 24)}**]]` }) ]\n var sub = []\n if (a.details != \"\") { sub.push(`**{truncName(a.details, 32)}**`) }\n if (a.state != \"\") { sub.push(truncName(a.state, 32)) }\n if (a.since != \"\") { sub.push(`⏱ {a.since}`) }\n if (sub.len > 0) { lines.push(Ui:C:mfm({ text: `$[fg.color={MUTED} {sub.join(\" · \")}]` })) }\n if (a.party != \"\") {\n let bar = partyBar(a.partyCur, a.partyMax)\n lines.push(Ui:C:mfm({ text: `$[fg.color={MUTED} 👥] {bar} $[fg.color={MUTED} {sanitizeText(a.party)}]` }))\n }\n var chips = []\n if (a.large != \"\") { chips.push(chip(CHIP_BG, \"ffffff\", truncName(a.large, 20))) }\n if (a.small != \"\" && a.small != a.large) { chips.push(chip(ACCENT, \"ffffff\", truncName(a.small, 20))) }\n if (chips.len > 0) { lines.push(Ui:C:mfm({ text: `{chips.join(\" \")}` })) }\n lines\n}\n\n/// 2 つ目以降のアクティビティは 1 行に畳む\n@minorActivity(a) {\n var txt = `{a.icon} **{truncName(a.name, 24)}**`\n if (a.details != \"\") { txt = `{txt} $[fg.color={MUTED} · {truncName(a.details, 24)}]` }\n Ui:C:mfm({ text: `{txt}` })\n}\n\n/// サーバーで今ゲーム中のメンバー (ゲーム別)。自分と同じゲームは ✨ で強調\n@guildView(g, myGame) {\n var body = [ divider() ]\n if (Obj:has(g, \"error\")) {\n body.push(Ui:C:mfm({ text: `$[fg.color={MUTED} 🧩 {g.error}]` }))\n return body\n }\n body.push(Ui:C:mfm({ text: `$[fg.color={MUTED} 🧩 {truncName(g.name, 24)} · $[fg.color={GREEN} ●] {g.online} オンライン]` }))\n if (g.games.len == 0) {\n body.push(Ui:C:mfm({ text: `$[fg.color={MUTED} 💤 今ゲーム中のメンバーはいません]` }))\n return body\n }\n var rows = []\n for (let i, Math:min(g.games.len, MAX_GAMES)) {\n let gm = g.games[i]\n var names = []\n for (let j, Math:min(gm.members.len, MAX_NAMES)) { names.push(truncName(gm.members[j], 16)) }\n var who = names.join(\", \")\n if (gm.members.len > MAX_NAMES) { who = `{who} 他 {gm.members.len - MAX_NAMES} 人` }\n let same = if (myGame != \"\" && gm.name == myGame) { ` $[fg.color={GREEN} ✨ 同じ]` } else { \"\" }\n rows.push(Ui:C:mfm({ text: `$[fg.color={ACCENT} ▸] **{truncName(gm.name, 24)}** {chip(ACCENT, \"ffffff\", `{gm.members.len}`)}{same}` }))\n rows.push(Ui:C:mfm({ text: ` $[fg.color={MUTED} {who}]` }))\n }\n if (g.games.len > MAX_GAMES) { rows.push(Ui:C:mfm({ text: `$[fg.color={MUTED} 他 {g.games.len - MAX_GAMES} タイトル]` })) }\n body.push(Ui:C:container({ align: \"left\", children: rows }))\n body\n}\n\n@statusView(r) {\n let me = r.me\n let info = statusInfo(me.status)\n // 主役はプレイ中 (type 0) を優先し、無ければ先頭。activities の並び順は保証されない\n var heroIdx = -1\n for (let i, me.acts.len) { if (heroIdx < 0 && me.acts[i].playing) { heroIdx = i } }\n if (heroIdx < 0 && me.acts.len > 0) { heroIdx = 0 }\n var playing = null\n var minors = []\n for (let i, me.acts.len) {\n if (i == heroIdx) { playing = me.acts[i] } else { minors.push(me.acts[i]) }\n }\n\n // 見出し: 💠 Discord · @username 名前 + ステータスチップ\n var body = [\n Ui:C:mfm({ text: `$[fg.color={MUTED} 💠 Discord · @{truncName(me.uname, 24)}]` })\n Ui:C:mfm({ text: `$[fg.color={info.col} ●] **{truncName(me.name, 20)}** {chip(info.col, BADGE_T, info.label)}` })\n ]\n\n if (me.status == \"offline\") {\n body.push(Ui:C:mfm({ text: `$[x2 $[fg.color={LINE} ○]]` }))\n body.push(Ui:C:mfm({ text: `$[fg.color={MUTED} 💤 Discord を開いていません]` }))\n } else {\n // 主役は 1 つ: アクティビティ > Spotify > ステータスのみ\n if (Core:type(playing) == \"obj\") {\n each (let l, heroActivity(playing)) { body.push(l) }\n } elif (Core:type(me.spotify) == \"obj\") {\n body.push(Ui:C:mfm({ text: `$[x2 $[fg.color={SPOTIFY} **🎵 {truncName(me.spotify.song, 20)}**]]` }))\n body.push(Ui:C:mfm({ text: `$[fg.color={MUTED} {truncName(me.spotify.artist, 32)} · Spotify]` }))\n } else {\n body.push(Ui:C:mfm({ text: `$[x2 $[fg.color={info.col} {info.icon}]]` }))\n body.push(Ui:C:mfm({ text: `$[fg.color={MUTED} 🛋️ 何もプレイしていません]` }))\n }\n if (Core:type(playing) == \"obj\" && Core:type(me.spotify) == \"obj\") {\n body.push(Ui:C:mfm({ text: `$[fg.color={SPOTIFY} 🎵] **{truncName(me.spotify.song, 24)}** $[fg.color={MUTED} — {truncName(me.spotify.artist, 20)}]` }))\n }\n each (let a, minors) { body.push(minorActivity(a)) }\n if (me.custom != \"\") { body.push(Ui:C:mfm({ text: `$[fg.color={MUTED} 💬 {truncName(me.custom, 40)}]` })) }\n if (me.platforms != \"\") { body.push(Ui:C:mfm({ text: `$[fg.color={MUTED} {me.platforms}]` })) }\n }\n\n if (Core:type(playing) == \"obj\" && playing.playing) {\n body.push(Ui:C:container({ align: \"center\", children: [\n Ui:C:postFormButton({ text: `{playing.icon} やってるとノートする`, primary: true, rounded: true, form: { text: noteText(playing) } })\n ] }))\n }\n let myGame = if (Core:type(playing) == \"obj\" && playing.playing) { playing.name } else { \"\" }\n if (Core:type(r.guild) == \"obj\") { each (let l, guildView(r.guild, myGame)) { body.push(l) } }\n body\n}\n\n@render() {\n if (!isSnowflake(userId)) {\n Ui:render([\n Ui:C:container({\n align: \"center\"\n padding: 10\n children: [\n Ui:C:mfm({ text: `$[fg.color={MUTED} 💠 Discord プレゼンス]` })\n Ui:C:mfm({ text: `$[fg.color={MUTED} 🎮 Discord のユーザー ID を設定してください]` })\n configFolder(true)\n ]\n })\n ])\n return null\n }\n if (firstRun && Core:type(result) == \"null\") {\n Ui:render([\n Ui:C:container({ align: \"center\", padding: 10, children: [ Ui:C:text({ text: \"Discord の状態を読み込み中… 💠\" }) ] })\n ])\n firstRun = false\n fetchData()\n return null\n }\n var children = []\n var footer = \"60 秒ごとに更新 · Lanyard · Discord ウィジェット\"\n if (Core:type(result) == \"null\") {\n children = [ Ui:C:container({ align: \"center\", children: [ Ui:C:button({ text: \"🔄 更新\", primary: true, rounded: true, onClick: @() { fetchData() } }) ] }) ]\n } elif (Obj:has(result, \"error\")) {\n children = [\n Ui:C:mfm({ text: `$[fg.color={RED} ⚠️ {result.error}]` })\n Ui:C:container({ align: \"center\", children: [ Ui:C:button({ text: \"🔄 再試行\", primary: true, rounded: true, onClick: @() { fetchData() } }) ] })\n ]\n } elif (Obj:has(result, \"unregistered\")) {\n children = [\n Ui:C:mfm({ text: `$[fg.color={MUTED} 🔍 このユーザーは Lanyard に未登録です]` })\n Ui:C:mfm({ text: `$[fg.color={MUTED} discord.gg/lanyard に参加すると表示できます]` })\n Ui:C:container({ align: \"center\", children: [ Ui:C:button({ text: \"🔄 再試行\", primary: true, rounded: true, onClick: @() { fetchData() } }) ] })\n ]\n } else {\n children = statusView(result)\n children.push(divider())\n children.push(Ui:C:container({ align: \"center\", children: [\n Ui:C:button({ text: \"🔄 更新\", rounded: true, onClick: @() { fetchData() } })\n ] }))\n footer = `最終更新 {timeLabel(result.fetchedAt)} · {footer}`\n }\n children.push(configFolder(false))\n children.push(Ui:C:mfm({ text: `$[fg.color={MUTED} {footer}]` }))\n Ui:render([ Ui:C:container({ align: \"center\", padding: 10, children: children }) ])\n}\n\nrender()\nAsync:interval(REFRESH_MS, fetchData, false)\n"}
+{"type":"widget","data":"/// @ 1.2.1\n/// Discord プレゼンス ウィジェット (Lanyard + サーバーウィジェット)\n/// 自分 (または指定ユーザー) の Discord の状態と Rich Presence (ゲーム名・モード・進行状況・\n/// 経過時間・パーティ人数・マップ / ランク等のツールチップ) を表示し、ゲーム中はワンタップで\n/// 「やってる」ノートにできる。共通サーバーの ID を入れると、そのサーバーで今ゲーム中の\n/// メンバーをゲーム別に一覧する (「今フレンドが何をしているか」)。API キー不要。\n///\n/// ■ データ源\n/// - 本人: Lanyard (https://api.lanyard.rest/v1/users/)。対象ユーザーが\n/// Discord の Lanyard サーバー (https://discord.gg/lanyard) に参加している必要がある\n/// - フレンド: Discord 公式のサーバーウィジェット (discord.com/api/guilds//widget.json)。\n/// サーバー設定 → ウィジェット を有効化してあるサーバーのみ。オンライン最大 100 人・表示名のみ\n///\n/// ■ 事前準備\n/// 1. Discord の設定 → 詳細設定 → 開発者モードを ON にし、自分を右クリック →「ユーザー ID をコピー」\n/// 2. 設定 (⚙️) にユーザー ID を貼る。フレンドも見たいならサーバーを右クリック →「サーバー ID をコピー」も貼る\n///\n/// 設計の元ネタ: discord-online (Lanyard) / nicolive-watch (設定 UI + 投稿ボタン)。\n/// 投稿ボタンはゲーム中だけ (Spotify 再生中は listenbrainz の #nowplaying と重なるので出さない)。\n\n// ─────────────────────────────\n// ヘルパー\n// ─────────────────────────────\n@dflt(v, d) { if (Core:type(v) == \"null\") d else v }\n\n@get(o, k, d) {\n if (Core:type(o) != \"obj\") { d }\n elif (Obj:has(o, k)) { dflt(o[k], d) }\n else { d }\n}\n\n@trim(s) {\n if (Core:type(s) != \"str\") { return \"\" }\n var a = 0\n var b = s.len\n loop { if (a < b && s.slice(a, a + 1) == \" \") { a = a + 1 } else { break } }\n loop { if (b > a && s.slice(b - 1, b) == \" \") { b = b - 1 } else { break } }\n s.slice(a, b)\n}\n\nlet DIGITS = \"0123456789\".split(\"\")\n/// Discord の snowflake ID か (数字のみ・17〜20 桁)。URL パスへのブレイクアウトを防ぐ\n@isSnowflake(s) {\n if (Core:type(s) != \"str\" || s.len < 17 || s.len > 20) { return false }\n var ok = true\n for (let i, s.len) { if (!DIGITS.incl(s.slice(i, i + 1))) { ok = false } }\n ok\n}\n\n/// MFM/HTML 制御文字を無害化。表示名・ゲーム名・サーバー名は外部由来なのでサニタイズ\n@sanitizeText(s) {\n if (Core:type(s) != \"str\") { return \"\" }\n var txt = s.replace(\"$[\", \"$ \")\n txt = txt.replace(\"]\", \"]\")\n txt = txt.replace(\"[\", \"[\")\n txt = txt.replace(\"<\", \"<\")\n txt = txt.replace(\">\", \">\")\n txt = txt.replace(\"`\", \"`\")\n txt = txt.replace(\"*\", \"*\")\n txt = txt.replace(\"\\\\\", \"\\")\n txt = txt.replace(\"@\", \"@\")\n txt = txt.replace(\"#\", \"#\")\n txt = txt.replace(\"://\", \"://\")\n txt\n}\n\n@truncName(s, n) {\n let clean = sanitizeText(s)\n if (clean.len > n) { `{clean.slice(0, n)}…` } else { clean }\n}\n\n@pad2(n) { if (n < 10) { `0{n}` } else { `{n}` } }\n\n/// 経過時間 \"30 分\" / \"2 時間 05 分\"\n@elapsed(startMs) {\n let mins = Math:floor((Date:now() - startMs) / 60000)\n if (mins < 1) { \"1 分未満\" }\n elif (mins < 60) { `{mins} 分` }\n else { `{Math:floor(mins / 60)} 時間 {pad2(mins % 60)} 分` }\n}\n\n// ─────────────────────────────\n// 定数\n// ─────────────────────────────\nlet REFRESH_MS = 60000 // Lanyard は 1 更新 1 リクエスト。hital.in と同じ 60 秒\nlet MAX_GAMES = 6 // サーバー一覧に出すゲーム数\nlet MAX_NAMES = 6 // ゲームごとに出す名前の数\n\nlet ACCENT = \"5865f2\" // Discord blurple\nlet MUTED = \"8a96a3\"\nlet LINE = \"5a6573\"\nlet GREEN = \"3dd68c\"\nlet YELLOW = \"ffb300\"\nlet RED = \"ff6961\"\nlet BADGE_T = \"0b0d12\" // チップ文字 (濃色)\nlet CHIP_BG = \"2f3a4a\" // 補足チップ (マップ / ランク) の背景\nlet SPOTIFY = \"1db954\"\n\n/// discord_status → { col, label, icon }\n@statusInfo(s) {\n if (s == \"online\") { { col: GREEN, label: \"オンライン\", icon: \"●\" } }\n elif (s == \"idle\") { { col: YELLOW, label: \"退席中\", icon: \"🌙\" } }\n elif (s == \"dnd\") { { col: RED, label: \"取り込み中\", icon: \"⛔\" } }\n elif (s == \"offline\") { { col: LINE, label: \"オフライン\", icon: \"○\" } }\n else { { col: LINE, label: \"不明\", icon: \"?\" } }\n}\n\n/// 角丸チップ。chunithm-rating / osu-stats と同じ `** X **` 詰め\n@chip(bg, fg, label) { `$[border.radius=4 $[bg.color={bg} $[fg.color={fg} ** {label} **]]]` }\n\n/// 文字の繰り返し (str に repeat は無い)\n@rep(ch, n) {\n var s = \"\"\n for (n) { s = `{s}{ch}` }\n s\n}\n\n/// パーティ人数バー \"▰▰▰▱▱\" (等幅)。6 人を超える party は数字だけ\n@partyBar(cur, max) {\n if (max <= 0 || max > 6) { return \"\" }\n let filled = Math:min(max, Math:max(0, cur))\n `$[font.monospace $[fg.color={ACCENT} {rep(\"▰\", filled)}]$[fg.color={LINE} {rep(\"▱\", max - filled)}]]`\n}\n\n/// activity.type → 絵文字 (0 プレイ中 / 1 配信中 / 2 再生中 / 3 視聴中 / 5 競技中)\n@actIcon(ty) {\n if (ty == 0) { \"🎮\" } elif (ty == 1) { \"📡\" } elif (ty == 2) { \"🎵\" } elif (ty == 3) { \"📺\" } elif (ty == 5) { \"🏆\" } else { \"✨\" }\n}\n\n// ─────────────────────────────\n// 状態\n// ─────────────────────────────\nvar userId = trim(dflt(Mk:load(\"dpUserId\"), \"\"))\nvar guildId = trim(dflt(Mk:load(\"dpGuildId\"), \"\"))\nvar result = null // null = 未取得 / { error } / { unregistered: true } / { me, guild }\nvar hasData = false // 一度でも表示に成功したか (通信失敗時のチラつき防止)\nvar lastStatus = 0\n/// Nd:http の失敗理由。status 0 は通信断 (DNS 失敗・接続拒否・タイムアウト) で、\n/// NoteDeck は throw せず値で返す (notedeck#1294)\n@httpLabel(st) { if (st == 0) { \"通信できません\" } else { `HTTP {st}` } }\nvar firstRun = true\n\n// ─────────────────────────────\n// データ取得\n// ─────────────────────────────\n@httpGet(url) {\n Nd:http(url, { method: \"GET\", headers: { \"Accept\": \"application/json\" } })\n}\n\n/// Lanyard の data → 表示用 { name, uname, status, platforms, custom, spotify, acts: [{icon,name,details,state,party,since,large,small,playing}] }\n@parsePresence(d) {\n let du = get(d, \"discord_user\", {})\n let uname = get(du, \"username\", \"\")\n var platforms = []\n if (get(d, \"active_on_discord_desktop\", false) == true) { platforms.push(\"🖥️ PC\") }\n if (get(d, \"active_on_discord_web\", false) == true) { platforms.push(\"🌐 Web\") }\n if (get(d, \"active_on_discord_mobile\", false) == true) { platforms.push(\"📱 Mobile\") }\n var custom = \"\"\n var acts = []\n each (let a, get(d, \"activities\", [])) {\n let ty = get(a, \"type\", -1)\n if (ty == 4) {\n let emo = get(get(a, \"emoji\", {}), \"name\", \"\")\n let stt = get(a, \"state\", \"\")\n custom = trim(`{emo} {stt}`)\n } elif (ty != 2 || get(d, \"listening_to_spotify\", false) != true) {\n let assets = get(a, \"assets\", {})\n let party = get(get(a, \"party\", {}), \"size\", [])\n let start = get(get(a, \"timestamps\", {}), \"start\", null)\n acts.push({\n icon: actIcon(ty)\n playing: ty == 0\n name: get(a, \"name\", \"\")\n details: get(a, \"details\", \"\")\n state: get(a, \"state\", \"\")\n party: if (Core:type(party) == \"arr\" && party.len == 2) { `{party[0]}/{party[1]}` } else { \"\" }\n partyCur: if (Core:type(party) == \"arr\" && party.len == 2 && Core:type(party[0]) == \"num\") { party[0] } else { 0 }\n partyMax: if (Core:type(party) == \"arr\" && party.len == 2 && Core:type(party[1]) == \"num\") { party[1] } else { 0 }\n since: if (Core:type(start) == \"num\" && start > 0) { elapsed(start) } else { \"\" }\n large: get(assets, \"large_text\", \"\")\n small: get(assets, \"small_text\", \"\")\n })\n }\n }\n var spotify = null\n if (get(d, \"listening_to_spotify\", false) == true) {\n let sp = get(d, \"spotify\", {})\n spotify = { song: get(sp, \"song\", \"\"), artist: get(sp, \"artist\", \"\") }\n }\n {\n name: get(du, \"global_name\", uname)\n uname: uname\n status: get(d, \"discord_status\", \"offline\")\n platforms: platforms.join(\" \")\n custom: custom\n spotify: spotify\n acts: acts\n }\n}\n\n/// widget.json → { name, online, games: [{ name, members: [] }] } (自分は除外、ゲーム無しは数えない)\n/// username は「表示名または検閲済みアカウント名」なので、自分は両方の名前で除外する (サーバー別ニックネームは一致しない)\n@parseGuild(g, myNames) {\n var byGame = {}\n var order = []\n each (let m, get(g, \"members\", [])) {\n let game = get(get(m, \"game\", {}), \"name\", \"\")\n let who = get(m, \"username\", \"\")\n if (game != \"\" && who != \"\" && !myNames.incl(who)) {\n if (!Obj:has(byGame, game)) { Obj:set(byGame, game, []); order.push(game) }\n byGame[game].push(who)\n }\n }\n var games = []\n each (let gname, order) { games.push({ name: gname, members: byGame[gname] }) }\n games = games.sort(@(a, b) { b.members.len - a.members.len })\n { name: get(g, \"name\", \"\"), online: get(g, \"presence_count\", 0), games: games }\n}\n\n@fetchGuild(myNames) {\n let res = httpGet(`https://discord.com/api/guilds/{guildId}/widget.json`)\n if (res.status == 403) { return { error: \"サーバーのウィジェットが無効です (サーバー設定 → ウィジェット で有効化)\" } }\n if (res.status == 404) { return { error: \"サーバーが見つかりません (サーバー ID を確認)\" } }\n if (res.status != 200) { return { error: `サーバー情報を取得できません ({httpLabel(res.status)})` } }\n let g = Json:parse(res.body)\n if (Core:type(g) != \"obj\") { return { error: \"サーバー情報を読めません\" } }\n parseGuild(g, myNames)\n}\n\n@fetchData() {\n if (!isSnowflake(userId)) {\n result = null\n render()\n return null\n }\n let res = httpGet(`https://api.lanyard.rest/v1/users/{userId}`)\n lastStatus = res.status\n // 200 = 取得成功 / 404 = Lanyard 未登録 (どちらも JSON)。それ以外は通信失敗扱い\n if (res.status != 200 && res.status != 404) {\n if (!hasData) { result = { error: `Discord の状態を取得できません ({httpLabel(res.status)})` }; render() }\n return null\n }\n let body = Json:parse(res.body)\n if (Core:type(body) != \"obj\") {\n if (!hasData) { result = { error: \"Discord の状態を読めません\" }; render() }\n return null\n }\n if (get(body, \"success\", false) != true) {\n result = { unregistered: true }\n render()\n return null\n }\n let me = parsePresence(get(body, \"data\", {}))\n var guild = null\n if (isSnowflake(guildId)) { guild = fetchGuild([me.uname, me.name]) }\n result = { me: me, guild: guild, fetchedAt: Date:now() }\n hasData = true\n render()\n}\n\n// ─────────────────────────────\n// UI 構築 (様式: steam / chunithm-rating / mewk。見出し small → 主役 x2 → 補足 small)\n// ─────────────────────────────\n@divider() { Ui:C:mfm({ text: `$[fg.color={LINE} ───────────────]` }) }\n\n@timeLabel(ms) { `{pad2(Date:hour(ms))}:{pad2(Date:minute(ms))}` }\n\n@configFolder(opened) {\n Ui:C:folder({\n title: \"⚙️ 設定\"\n opened: opened\n children: [\n Ui:C:textInput({\n // 入力ごとに fetch すると再描画でフォーカスが飛ぶので保存のみ。反映は「適用」で\n // ID を変えたら前のユーザーの表示を維持しない (次の取得が失敗したらエラーを出す)\n onInput: @(v) { userId = trim(v); hasData = false; Mk:save(\"dpUserId\", userId) }\n default: userId\n label: \"Discord ユーザー ID\"\n caption: \"開発者モード ON → 自分を右クリック → ユーザー ID をコピー。Lanyard サーバー参加が必要\"\n })\n Ui:C:textInput({\n onInput: @(v) { guildId = trim(v); Mk:save(\"dpGuildId\", guildId) }\n default: guildId\n label: \"サーバー ID (任意)\"\n caption: \"ウィジェットを有効化したサーバーの ID。今ゲーム中のメンバーを一覧する\"\n })\n Ui:C:button({ text: \"✅ 適用して再読み込み\", rounded: true, onClick: @() { fetchData() } })\n ]\n })\n}\n\n/// 投稿本文。進行状況 (state) にはスコアや相手が入るので入れない\n@noteText(a) {\n var txt = `{a.icon} {a.name} やってる`\n if (a.details != \"\") { txt = `{txt} ({a.details})` }\n if (a.large != \"\" && a.large != a.details) { txt = `{txt} · {a.large}` }\n txt\n}\n\n/// 主役のアクティビティ: x2 のゲーム名 → 補足 (モード · 進行 · ⏱) → パーティバー → マップ / ランクのチップ\n@heroActivity(a) {\n var lines = [ Ui:C:mfm({ text: `$[x2 $[fg.color={ACCENT} **{a.icon} {truncName(a.name, 24)}**]]` }) ]\n var sub = []\n if (a.details != \"\") { sub.push(`**{truncName(a.details, 32)}**`) }\n if (a.state != \"\") { sub.push(truncName(a.state, 32)) }\n if (a.since != \"\") { sub.push(`⏱ {a.since}`) }\n if (sub.len > 0) { lines.push(Ui:C:mfm({ text: `$[fg.color={MUTED} {sub.join(\" · \")}]` })) }\n if (a.party != \"\") {\n let bar = partyBar(a.partyCur, a.partyMax)\n lines.push(Ui:C:mfm({ text: `$[fg.color={MUTED} 👥] {bar} $[fg.color={MUTED} {sanitizeText(a.party)}]` }))\n }\n var chips = []\n if (a.large != \"\") { chips.push(chip(CHIP_BG, \"ffffff\", truncName(a.large, 20))) }\n if (a.small != \"\" && a.small != a.large) { chips.push(chip(ACCENT, \"ffffff\", truncName(a.small, 20))) }\n if (chips.len > 0) { lines.push(Ui:C:mfm({ text: `{chips.join(\" \")}` })) }\n lines\n}\n\n/// 2 つ目以降のアクティビティは 1 行に畳む\n@minorActivity(a) {\n var txt = `{a.icon} **{truncName(a.name, 24)}**`\n if (a.details != \"\") { txt = `{txt} $[fg.color={MUTED} · {truncName(a.details, 24)}]` }\n Ui:C:mfm({ text: `{txt}` })\n}\n\n/// サーバーで今ゲーム中のメンバー (ゲーム別)。自分と同じゲームは ✨ で強調\n@guildView(g, myGame) {\n var body = [ divider() ]\n if (Obj:has(g, \"error\")) {\n body.push(Ui:C:mfm({ text: `$[fg.color={MUTED} 🧩 {g.error}]` }))\n return body\n }\n body.push(Ui:C:mfm({ text: `$[fg.color={MUTED} 🧩 {truncName(g.name, 24)} · $[fg.color={GREEN} ●] {g.online} オンライン]` }))\n if (g.games.len == 0) {\n body.push(Ui:C:mfm({ text: `$[fg.color={MUTED} 💤 今ゲーム中のメンバーはいません]` }))\n return body\n }\n var rows = []\n for (let i, Math:min(g.games.len, MAX_GAMES)) {\n let gm = g.games[i]\n var names = []\n for (let j, Math:min(gm.members.len, MAX_NAMES)) { names.push(truncName(gm.members[j], 16)) }\n var who = names.join(\", \")\n if (gm.members.len > MAX_NAMES) { who = `{who} 他 {gm.members.len - MAX_NAMES} 人` }\n let same = if (myGame != \"\" && gm.name == myGame) { ` $[fg.color={GREEN} ✨ 同じ]` } else { \"\" }\n rows.push(Ui:C:mfm({ text: `$[fg.color={ACCENT} ▸] **{truncName(gm.name, 24)}** {chip(ACCENT, \"ffffff\", `{gm.members.len}`)}{same}` }))\n rows.push(Ui:C:mfm({ text: ` $[fg.color={MUTED} {who}]` }))\n }\n if (g.games.len > MAX_GAMES) { rows.push(Ui:C:mfm({ text: `$[fg.color={MUTED} 他 {g.games.len - MAX_GAMES} タイトル]` })) }\n body.push(Ui:C:container({ align: \"left\", children: rows }))\n body\n}\n\n@statusView(r) {\n let me = r.me\n let info = statusInfo(me.status)\n // 主役はプレイ中 (type 0) を優先し、無ければ先頭。activities の並び順は保証されない\n var heroIdx = -1\n for (let i, me.acts.len) { if (heroIdx < 0 && me.acts[i].playing) { heroIdx = i } }\n if (heroIdx < 0 && me.acts.len > 0) { heroIdx = 0 }\n var playing = null\n var minors = []\n for (let i, me.acts.len) {\n if (i == heroIdx) { playing = me.acts[i] } else { minors.push(me.acts[i]) }\n }\n\n // 見出し: 💠 Discord · @username 名前 + ステータスチップ\n var body = [\n Ui:C:mfm({ text: `$[fg.color={MUTED} 💠 Discord · @{truncName(me.uname, 24)}]` })\n Ui:C:mfm({ text: `$[fg.color={info.col} ●] **{truncName(me.name, 20)}** {chip(info.col, BADGE_T, info.label)}` })\n ]\n\n if (me.status == \"offline\") {\n body.push(Ui:C:mfm({ text: `$[x2 $[fg.color={LINE} ○]]` }))\n body.push(Ui:C:mfm({ text: `$[fg.color={MUTED} 💤 Discord を開いていません]` }))\n } else {\n // 主役は 1 つ: アクティビティ > Spotify > ステータスのみ\n if (Core:type(playing) == \"obj\") {\n each (let l, heroActivity(playing)) { body.push(l) }\n } elif (Core:type(me.spotify) == \"obj\") {\n body.push(Ui:C:mfm({ text: `$[x2 $[fg.color={SPOTIFY} **🎵 {truncName(me.spotify.song, 20)}**]]` }))\n body.push(Ui:C:mfm({ text: `$[fg.color={MUTED} {truncName(me.spotify.artist, 32)} · Spotify]` }))\n } else {\n body.push(Ui:C:mfm({ text: `$[x2 $[fg.color={info.col} {info.icon}]]` }))\n body.push(Ui:C:mfm({ text: `$[fg.color={MUTED} 🛋️ 何もプレイしていません]` }))\n }\n if (Core:type(playing) == \"obj\" && Core:type(me.spotify) == \"obj\") {\n body.push(Ui:C:mfm({ text: `$[fg.color={SPOTIFY} 🎵] **{truncName(me.spotify.song, 24)}** $[fg.color={MUTED} — {truncName(me.spotify.artist, 20)}]` }))\n }\n each (let a, minors) { body.push(minorActivity(a)) }\n if (me.custom != \"\") { body.push(Ui:C:mfm({ text: `$[fg.color={MUTED} 💬 {truncName(me.custom, 40)}]` })) }\n if (me.platforms != \"\") { body.push(Ui:C:mfm({ text: `$[fg.color={MUTED} {me.platforms}]` })) }\n }\n\n if (Core:type(playing) == \"obj\" && playing.playing) {\n body.push(Ui:C:container({ align: \"center\", children: [\n Ui:C:postFormButton({ text: `{playing.icon} やってるとノートする`, primary: true, rounded: true, form: { text: noteText(playing) } })\n ] }))\n }\n let myGame = if (Core:type(playing) == \"obj\" && playing.playing) { playing.name } else { \"\" }\n if (Core:type(r.guild) == \"obj\") { each (let l, guildView(r.guild, myGame)) { body.push(l) } }\n body\n}\n\n@render() {\n if (!isSnowflake(userId)) {\n Ui:render([\n Ui:C:container({\n align: \"center\"\n padding: 10\n children: [\n Ui:C:mfm({ text: `$[fg.color={MUTED} 💠 Discord プレゼンス]` })\n Ui:C:mfm({ text: `$[fg.color={MUTED} 🎮 Discord のユーザー ID を設定してください]` })\n configFolder(true)\n ]\n })\n ])\n return null\n }\n if (firstRun && Core:type(result) == \"null\") {\n Ui:render([\n Ui:C:container({ align: \"center\", padding: 10, children: [ Ui:C:text({ text: \"Discord の状態を読み込み中… 💠\" }) ] })\n ])\n firstRun = false\n fetchData()\n return null\n }\n var children = []\n var footer = \"60 秒ごとに更新 · Lanyard · Discord ウィジェット\"\n if (Core:type(result) == \"null\") {\n children = [ Ui:C:container({ align: \"center\", children: [ Ui:C:button({ text: \"🔄 更新\", primary: true, rounded: true, onClick: @() { fetchData() } }) ] }) ]\n } elif (Obj:has(result, \"error\")) {\n children = [\n Ui:C:mfm({ text: `$[fg.color={RED} ⚠️ {result.error}]` })\n Ui:C:container({ align: \"center\", children: [ Ui:C:button({ text: \"🔄 再試行\", primary: true, rounded: true, onClick: @() { fetchData() } }) ] })\n ]\n } elif (Obj:has(result, \"unregistered\")) {\n children = [\n Ui:C:mfm({ text: `$[fg.color={MUTED} 🔍 このユーザーは Lanyard に未登録です]` })\n Ui:C:mfm({ text: `$[fg.color={MUTED} discord.gg/lanyard に参加すると表示できます]` })\n Ui:C:container({ align: \"center\", children: [ Ui:C:button({ text: \"🔄 再試行\", primary: true, rounded: true, onClick: @() { fetchData() } }) ] })\n ]\n } else {\n children = statusView(result)\n children.push(divider())\n children.push(Ui:C:container({ align: \"center\", children: [\n Ui:C:button({ text: \"🔄 更新\", rounded: true, onClick: @() { fetchData() } })\n ] }))\n footer = `最終更新 {timeLabel(result.fetchedAt)} · {footer}`\n }\n children.push(configFolder(false))\n children.push(Ui:C:mfm({ text: `$[fg.color={MUTED} {footer}]` }))\n Ui:render([ Ui:C:container({ align: \"center\", padding: 10, children: children }) ])\n}\n\nrender()\nAsync:interval(REFRESH_MS, fetchData, false)\n"}
diff --git a/public/registry/widgets/discord-presence/widget.is b/public/registry/widgets/discord-presence/widget.is
index 09065c7..5d93652 100644
--- a/public/registry/widgets/discord-presence/widget.is
+++ b/public/registry/widgets/discord-presence/widget.is
@@ -135,6 +135,9 @@ var guildId = trim(dflt(Mk:load("dpGuildId"), ""))
var result = null // null = 未取得 / { error } / { unregistered: true } / { me, guild }
var hasData = false // 一度でも表示に成功したか (通信失敗時のチラつき防止)
var lastStatus = 0
+/// Nd:http の失敗理由。status 0 は通信断 (DNS 失敗・接続拒否・タイムアウト) で、
+/// NoteDeck は throw せず値で返す (notedeck#1294)
+@httpLabel(st) { if (st == 0) { "通信できません" } else { `HTTP {st}` } }
var firstRun = true
// ─────────────────────────────
@@ -218,7 +221,7 @@ var firstRun = true
let res = httpGet(`https://discord.com/api/guilds/{guildId}/widget.json`)
if (res.status == 403) { return { error: "サーバーのウィジェットが無効です (サーバー設定 → ウィジェット で有効化)" } }
if (res.status == 404) { return { error: "サーバーが見つかりません (サーバー ID を確認)" } }
- if (res.status != 200) { return { error: `サーバー情報を取得できません (HTTP {res.status})` } }
+ if (res.status != 200) { return { error: `サーバー情報を取得できません ({httpLabel(res.status)})` } }
let g = Json:parse(res.body)
if (Core:type(g) != "obj") { return { error: "サーバー情報を読めません" } }
parseGuild(g, myNames)
@@ -234,7 +237,7 @@ var firstRun = true
lastStatus = res.status
// 200 = 取得成功 / 404 = Lanyard 未登録 (どちらも JSON)。それ以外は通信失敗扱い
if (res.status != 200 && res.status != 404) {
- if (!hasData) { result = { error: `Discord の状態を取得できません (HTTP {res.status})` }; render() }
+ if (!hasData) { result = { error: `Discord の状態を取得できません ({httpLabel(res.status)})` }; render() }
return null
}
let body = Json:parse(res.body)
diff --git a/public/registry/widgets/earthquake/api.json b/public/registry/widgets/earthquake/api.json
index 2f3a0d1..bcd7510 100644
--- a/public/registry/widgets/earthquake/api.json
+++ b/public/registry/widgets/earthquake/api.json
@@ -1 +1 @@
-{"type":"widget","data":"/// @ 1.2.1\n/// 地震速報ウィジェット\n/// Wolfx API (https://wolfx.jp/) の JMA 地震情報を表示。API キー不要。\n/// 最新の地震情報を大きく、直近の履歴をリスト表示する。\n/// 元ネタ: misskey-tempura WidgetEarthQuake (lqvp) を Wolfx + AiScript で再実装。\n\n// 表示する件数 (最新 1 件 + 履歴)。ここを書き換えると増減できる。\nlet COUNT = 5\n\n@dflt(v, d) {\n if (Core:type(v) == \"null\") d else v\n}\n\n@get(o, k, d) {\n if (Core:type(o) != \"obj\") {\n d\n } elif (Obj:has(o, k)) {\n dflt(o[k], d)\n } else {\n d\n }\n}\n\nvar firstRun = true\n\n/// 震度の表示ラベル (\"5-\" → \"5弱\" など)\n@shindoLabel(s) {\n if (s == \"5-\") \"5弱\"\n elif (s == \"5+\") \"5強\"\n elif (s == \"6-\") \"6弱\"\n elif (s == \"6+\") \"6強\"\n elif (s == \"\" || Core:type(s) == \"null\") \"不明\"\n else s\n}\n\n/// 震度階級に応じた背景色 (JMA の震度階級カラーに準拠)\n@shindoColor(s) {\n if (s == \"1\") \"808080\"\n elif (s == \"2\") \"1e88e5\"\n elif (s == \"3\") \"43a047\"\n elif (s == \"4\") \"e6a700\"\n elif (s == \"5-\") \"fb8c00\"\n elif (s == \"5+\") \"f4511e\"\n elif (s == \"6-\") \"e53935\"\n elif (s == \"6+\") \"c62828\"\n elif (s == \"7\") \"8e24aa\"\n else \"808080\"\n}\n\n/// 震度バッジ (MFM の背景色付きラベル)\n@shindoBadge(s) {\n `$[bg.color={shindoColor(s)} $[fg.color=ffffff 震度{shindoLabel(s)} ]]`\n}\n\n@fetchQuakes() {\n let url = \"https://api.wolfx.jp/jma_eqlist.json\"\n let res = Nd:http(url, { method: \"GET\", headers: { \"Accept\": \"application/json\" } })\n // 非 200 時に Json:parse が例外を投げてウィジェット全体が空白になるのを防ぐ\n if (res.status != 200) { return null }\n Json:parse(res.body)\n}\n\n@quakeView() {\n let data = fetchQuakes()\n let latest = get(data, \"No1\", null)\n if (Core:type(latest) == \"null\") {\n [\n Ui:C:mfm({ text: \"$[fg.color=f74e52 ⚠️ 地震情報を取得できませんでした]\" })\n ]\n } else {\n let s = get(latest, \"shindo\", \"不明\")\n var body = [\n Ui:C:mfm({ text: \"🌏 地震情報\" })\n Ui:C:mfm({ text: `$[x2 {shindoBadge(s)}]` })\n Ui:C:mfm({ text: `$[fg.color=ffb445 {get(latest, \"location\", \"不明\")}]` })\n Ui:C:mfm({ text: `M{get(latest, \"magnitude\", \"-\")} 深さ{get(latest, \"depth\", \"-\")}` })\n Ui:C:mfm({ text: `$[fg.color=999999 {get(latest, \"time\", \"\")}]` })\n ]\n\n // 履歴 (No2 以降)\n for (let i, COUNT - 1) {\n let q = get(data, `No{i + 2}`, null)\n if (Core:type(q) != \"null\") {\n body = body.concat([\n Ui:C:mfm({\n text: `{shindoBadge(get(q, \"shindo\", \"不明\"))} {get(q, \"location\", \"不明\")} $[fg.color=999999 M{get(q, \"magnitude\", \"-\")}]`\n })\n ])\n }\n }\n body\n }\n}\n\n@render() {\n if (firstRun) {\n Ui:render([\n Ui:C:container({\n align: \"center\"\n children: [ Ui:C:text({ text: \"地震情報を読み込み中… 🌏\" }) ]\n })\n ])\n }\n firstRun = false\n\n let body = quakeView()\n Ui:render([\n Ui:C:container({\n align: \"center\"\n children: body\n })\n ])\n}\n\nrender()\nAsync:interval(120000, render, false)\n"}
+{"type":"widget","data":"/// @ 1.2.1\n/// 地震速報ウィジェット\n/// Wolfx API (https://wolfx.jp/) の JMA 地震情報を表示。API キー不要。\n/// 最新の地震情報を大きく、直近の履歴をリスト表示する。\n/// 元ネタ: misskey-tempura WidgetEarthQuake (lqvp) を Wolfx + AiScript で再実装。\n\n// 表示する件数 (最新 1 件 + 履歴)。ここを書き換えると増減できる。\nlet COUNT = 5\n\n@dflt(v, d) {\n if (Core:type(v) == \"null\") d else v\n}\n\n@get(o, k, d) {\n if (Core:type(o) != \"obj\") {\n d\n } elif (Obj:has(o, k)) {\n dflt(o[k], d)\n } else {\n d\n }\n}\n\nvar firstRun = true\n\n/// 震度の表示ラベル (\"5-\" → \"5弱\" など)\n@shindoLabel(s) {\n if (s == \"5-\") \"5弱\"\n elif (s == \"5+\") \"5強\"\n elif (s == \"6-\") \"6弱\"\n elif (s == \"6+\") \"6強\"\n elif (s == \"\" || Core:type(s) == \"null\") \"不明\"\n else s\n}\n\n/// 震度階級に応じた背景色 (JMA の震度階級カラーに準拠)\n@shindoColor(s) {\n if (s == \"1\") \"808080\"\n elif (s == \"2\") \"1e88e5\"\n elif (s == \"3\") \"43a047\"\n elif (s == \"4\") \"e6a700\"\n elif (s == \"5-\") \"fb8c00\"\n elif (s == \"5+\") \"f4511e\"\n elif (s == \"6-\") \"e53935\"\n elif (s == \"6+\") \"c62828\"\n elif (s == \"7\") \"8e24aa\"\n else \"808080\"\n}\n\n/// 震度バッジ (MFM の背景色付きラベル)\n@shindoBadge(s) {\n `$[bg.color={shindoColor(s)} $[fg.color=ffffff 震度{shindoLabel(s)} ]]`\n}\n\nvar lastStatus = 0 // 直近の HTTP ステータス (エラー表示用。0 = 通信断、notedeck#1294)\n\n@fetchQuakes() {\n let url = \"https://api.wolfx.jp/jma_eqlist.json\"\n let res = Nd:http(url, { method: \"GET\", headers: { \"Accept\": \"application/json\" } })\n lastStatus = res.status\n // 非 200 時に Json:parse が例外を投げてウィジェット全体が空白になるのを防ぐ\n if (res.status != 200) { return null }\n Json:parse(res.body)\n}\n\n@quakeView() {\n let data = fetchQuakes()\n let latest = get(data, \"No1\", null)\n if (Core:type(latest) == \"null\") {\n let reason = if (lastStatus == 0) { \" (通信できません)\" } elif (lastStatus != 200) { ` (HTTP {lastStatus})` } else { \"\" }\n [\n Ui:C:mfm({ text: `$[fg.color=f74e52 ⚠️ 地震情報を取得できませんでした{reason}]` })\n ]\n } else {\n let s = get(latest, \"shindo\", \"不明\")\n var body = [\n Ui:C:mfm({ text: \"🌏 地震情報\" })\n Ui:C:mfm({ text: `$[x2 {shindoBadge(s)}]` })\n Ui:C:mfm({ text: `$[fg.color=ffb445 {get(latest, \"location\", \"不明\")}]` })\n Ui:C:mfm({ text: `M{get(latest, \"magnitude\", \"-\")} 深さ{get(latest, \"depth\", \"-\")}` })\n Ui:C:mfm({ text: `$[fg.color=999999 {get(latest, \"time\", \"\")}]` })\n ]\n\n // 履歴 (No2 以降)\n for (let i, COUNT - 1) {\n let q = get(data, `No{i + 2}`, null)\n if (Core:type(q) != \"null\") {\n body = body.concat([\n Ui:C:mfm({\n text: `{shindoBadge(get(q, \"shindo\", \"不明\"))} {get(q, \"location\", \"不明\")} $[fg.color=999999 M{get(q, \"magnitude\", \"-\")}]`\n })\n ])\n }\n }\n body\n }\n}\n\n@render() {\n if (firstRun) {\n Ui:render([\n Ui:C:container({\n align: \"center\"\n children: [ Ui:C:text({ text: \"地震情報を読み込み中… 🌏\" }) ]\n })\n ])\n }\n firstRun = false\n\n let body = quakeView()\n Ui:render([\n Ui:C:container({\n align: \"center\"\n children: body\n })\n ])\n}\n\nrender()\nAsync:interval(120000, render, false)\n"}
diff --git a/public/registry/widgets/earthquake/widget.is b/public/registry/widgets/earthquake/widget.is
index 40b3447..c1fbb3d 100644
--- a/public/registry/widgets/earthquake/widget.is
+++ b/public/registry/widgets/earthquake/widget.is
@@ -52,9 +52,12 @@ var firstRun = true
`$[bg.color={shindoColor(s)} $[fg.color=ffffff 震度{shindoLabel(s)} ]]`
}
+var lastStatus = 0 // 直近の HTTP ステータス (エラー表示用。0 = 通信断、notedeck#1294)
+
@fetchQuakes() {
let url = "https://api.wolfx.jp/jma_eqlist.json"
let res = Nd:http(url, { method: "GET", headers: { "Accept": "application/json" } })
+ lastStatus = res.status
// 非 200 時に Json:parse が例外を投げてウィジェット全体が空白になるのを防ぐ
if (res.status != 200) { return null }
Json:parse(res.body)
@@ -64,8 +67,9 @@ var firstRun = true
let data = fetchQuakes()
let latest = get(data, "No1", null)
if (Core:type(latest) == "null") {
+ let reason = if (lastStatus == 0) { " (通信できません)" } elif (lastStatus != 200) { ` (HTTP {lastStatus})` } else { "" }
[
- Ui:C:mfm({ text: "$[fg.color=f74e52 ⚠️ 地震情報を取得できませんでした]" })
+ Ui:C:mfm({ text: `$[fg.color=f74e52 ⚠️ 地震情報を取得できませんでした{reason}]` })
]
} else {
let s = get(latest, "shindo", "不明")
diff --git a/public/registry/widgets/github-releases/api.json b/public/registry/widgets/github-releases/api.json
index 1e7a9f6..a97ffb0 100644
--- a/public/registry/widgets/github-releases/api.json
+++ b/public/registry/widgets/github-releases/api.json
@@ -1 +1 @@
-{"type":"widget","data":"/// @ 1.2.1\n/// リリースウィジェット\n/// GitHub Releases API から任意リポジトリの最新リリースと履歴を表示。API キー不要。\n/// デフォルトは misskey-dev/misskey。設定で任意の \"owner/repo\" に変更できる。\n/// 設計の元ネタ: earthquake ウィジェット (表示・更新ループ) + habitica-status (設定 UI)。\n\n// 表示する件数 (最新 1 件 + 履歴)。ここを書き換えると増減できる。\nlet COUNT = 6\n\n@dflt(v, d) {\n if (Core:type(v) == \"null\") d else v\n}\n\n@get(o, k, d) {\n if (Core:type(o) != \"obj\") {\n d\n } elif (Obj:has(o, k)) {\n dflt(o[k], d)\n } else {\n d\n }\n}\n\n// 設定: 対象リポジトリ (\"owner/repo\")。Mk:save で永続化される。\nvar repo = dflt(Mk:load(\"repo\"), \"misskey-dev/misskey\")\nvar firstRun = true\n\n/// ISO 8601 (\"2026-06-10T12:34:56Z\") から日付部分だけ取り出す\n@dateLabel(s) {\n if (Core:type(s) == \"str\" && s.len >= 10) s.slice(0, 10) else \"-\"\n}\n\n/// 背景色付きラベルバッジ (MFM)\n@badge(text, color) {\n `$[bg.color={color} $[fg.color=ffffff {text} ]]`\n}\n\n@configFolder() {\n Ui:C:folder({\n title: \"⚙️ 設定\"\n opened: false\n children: [\n Ui:C:textInput({\n onInput: @(v) {\n repo = v\n Mk:save(\"repo\", v)\n firstRun = true\n render()\n }\n default: repo\n label: \"リポジトリ\"\n text: \"owner/repo (例: misskey-dev/misskey)\"\n })\n ]\n })\n}\n\n@fetchReleases() {\n let url = `https://api.github.com/repos/{repo}/releases?per_page={COUNT}`\n let res = Nd:http(url, {\n method: \"GET\"\n headers: {\n \"User-Agent\": \"notedeck-github-releases-widget\"\n \"Accept\": \"application/vnd.github+json\"\n }\n })\n if (res.status != 200) {\n { error: `取得失敗 (HTTP {res.status})` }\n } else {\n { releases: Json:parse(res.body) }\n }\n}\n\n/// 1 件のリリースを 1 行のリスト項目にする\n@releaseRow(r) {\n let tag = get(r, \"tag_name\", \"?\")\n let url = get(r, \"html_url\", \"\")\n let date = dateLabel(get(r, \"published_at\", \"\"))\n Ui:C:mfm({\n text: `$[fg.color=86b300 ●] [{tag}]({url}) $[fg.color=999999 {date}]`\n })\n}\n\n@releasesView() {\n let data = fetchReleases()\n\n // 取得失敗\n if (Core:type(get(data, \"error\", null)) != \"null\") {\n return [\n Ui:C:mfm({ text: `🚀 $[fg.color=999999 {repo}]` })\n Ui:C:mfm({ text: `$[fg.color=f74e52 ⚠️ {data.error}]` })\n configFolder()\n ]\n }\n\n let releases = get(data, \"releases\", [])\n if (Core:type(releases) != \"arr\" || releases.len == 0) {\n return [\n Ui:C:mfm({ text: `🚀 $[fg.color=999999 {repo}]` })\n Ui:C:mfm({ text: \"$[fg.color=999999 リリースがありません]\" })\n configFolder()\n ]\n }\n\n // 最新リリース (大きく表示)\n let latest = releases[0]\n let tag = get(latest, \"tag_name\", \"?\")\n let url = get(latest, \"html_url\", \"\")\n let pre = get(latest, \"prerelease\", false)\n let stateBadge = if (pre) badge(\"Pre-release\", \"f59e0b\") else badge(\"Stable\", \"43a047\")\n\n var body = [\n Ui:C:mfm({ text: `🚀 $[fg.color=999999 {repo}]` })\n Ui:C:mfm({ text: `$[x2 [**{tag}**]({url})]` })\n Ui:C:mfm({ text: `{stateBadge}` })\n Ui:C:mfm({ text: `$[fg.color=999999 {dateLabel(get(latest, \"published_at\", \"\"))} 公開]` })\n ]\n\n // 履歴 (2 件目以降)\n for (let i, releases.len - 1) {\n body = body.concat([ releaseRow(releases[i + 1]) ])\n }\n\n body.concat([ configFolder() ])\n}\n\n@render() {\n if (firstRun) {\n Ui:render([\n Ui:C:container({\n align: \"center\"\n children: [ Ui:C:text({ text: \"リリース情報を読み込み中… 🚀\" }) ]\n })\n ])\n }\n firstRun = false\n\n let body = releasesView()\n Ui:render([\n Ui:C:container({\n align: \"center\"\n children: body\n })\n ])\n}\n\nrender()\n// リリースは頻繁に出ないので 5 分間隔で更新\nAsync:interval(300000, render, false)\n"}
+{"type":"widget","data":"/// @ 1.2.1\n/// リリースウィジェット\n/// GitHub Releases API から任意リポジトリの最新リリースと履歴を表示。API キー不要。\n/// デフォルトは misskey-dev/misskey。設定で任意の \"owner/repo\" に変更できる。\n/// 設計の元ネタ: earthquake ウィジェット (表示・更新ループ) + habitica-status (設定 UI)。\n\n// 表示する件数 (最新 1 件 + 履歴)。ここを書き換えると増減できる。\nlet COUNT = 6\n\n@dflt(v, d) {\n if (Core:type(v) == \"null\") d else v\n}\n\n@get(o, k, d) {\n if (Core:type(o) != \"obj\") {\n d\n } elif (Obj:has(o, k)) {\n dflt(o[k], d)\n } else {\n d\n }\n}\n\n// 設定: 対象リポジトリ (\"owner/repo\")。Mk:save で永続化される。\nvar repo = dflt(Mk:load(\"repo\"), \"misskey-dev/misskey\")\nvar firstRun = true\n\n/// ISO 8601 (\"2026-06-10T12:34:56Z\") から日付部分だけ取り出す\n@dateLabel(s) {\n if (Core:type(s) == \"str\" && s.len >= 10) s.slice(0, 10) else \"-\"\n}\n\n/// 背景色付きラベルバッジ (MFM)\n@badge(text, color) {\n `$[bg.color={color} $[fg.color=ffffff {text} ]]`\n}\n\n@configFolder() {\n Ui:C:folder({\n title: \"⚙️ 設定\"\n opened: false\n children: [\n Ui:C:textInput({\n onInput: @(v) {\n repo = v\n Mk:save(\"repo\", v)\n firstRun = true\n render()\n }\n default: repo\n label: \"リポジトリ\"\n text: \"owner/repo (例: misskey-dev/misskey)\"\n })\n ]\n })\n}\n\n@fetchReleases() {\n let url = `https://api.github.com/repos/{repo}/releases?per_page={COUNT}`\n let res = Nd:http(url, {\n method: \"GET\"\n headers: {\n \"User-Agent\": \"notedeck-github-releases-widget\"\n \"Accept\": \"application/vnd.github+json\"\n }\n })\n if (res.status == 0) {\n // 通信断 (DNS 失敗・接続拒否・タイムアウト)。NoteDeck は throw せず status 0 で返す (notedeck#1294)\n { error: \"通信できません\" }\n } elif (res.status != 200) {\n { error: `取得失敗 (HTTP {res.status})` }\n } else {\n { releases: Json:parse(res.body) }\n }\n}\n\n/// 1 件のリリースを 1 行のリスト項目にする\n@releaseRow(r) {\n let tag = get(r, \"tag_name\", \"?\")\n let url = get(r, \"html_url\", \"\")\n let date = dateLabel(get(r, \"published_at\", \"\"))\n Ui:C:mfm({\n text: `$[fg.color=86b300 ●] [{tag}]({url}) $[fg.color=999999 {date}]`\n })\n}\n\n@releasesView() {\n let data = fetchReleases()\n\n // 取得失敗\n if (Core:type(get(data, \"error\", null)) != \"null\") {\n return [\n Ui:C:mfm({ text: `🚀 $[fg.color=999999 {repo}]` })\n Ui:C:mfm({ text: `$[fg.color=f74e52 ⚠️ {data.error}]` })\n configFolder()\n ]\n }\n\n let releases = get(data, \"releases\", [])\n if (Core:type(releases) != \"arr\" || releases.len == 0) {\n return [\n Ui:C:mfm({ text: `🚀 $[fg.color=999999 {repo}]` })\n Ui:C:mfm({ text: \"$[fg.color=999999 リリースがありません]\" })\n configFolder()\n ]\n }\n\n // 最新リリース (大きく表示)\n let latest = releases[0]\n let tag = get(latest, \"tag_name\", \"?\")\n let url = get(latest, \"html_url\", \"\")\n let pre = get(latest, \"prerelease\", false)\n let stateBadge = if (pre) badge(\"Pre-release\", \"f59e0b\") else badge(\"Stable\", \"43a047\")\n\n var body = [\n Ui:C:mfm({ text: `🚀 $[fg.color=999999 {repo}]` })\n Ui:C:mfm({ text: `$[x2 [**{tag}**]({url})]` })\n Ui:C:mfm({ text: `{stateBadge}` })\n Ui:C:mfm({ text: `$[fg.color=999999 {dateLabel(get(latest, \"published_at\", \"\"))} 公開]` })\n ]\n\n // 履歴 (2 件目以降)\n for (let i, releases.len - 1) {\n body = body.concat([ releaseRow(releases[i + 1]) ])\n }\n\n body.concat([ configFolder() ])\n}\n\n@render() {\n if (firstRun) {\n Ui:render([\n Ui:C:container({\n align: \"center\"\n children: [ Ui:C:text({ text: \"リリース情報を読み込み中… 🚀\" }) ]\n })\n ])\n }\n firstRun = false\n\n let body = releasesView()\n Ui:render([\n Ui:C:container({\n align: \"center\"\n children: body\n })\n ])\n}\n\nrender()\n// リリースは頻繁に出ないので 5 分間隔で更新\nAsync:interval(300000, render, false)\n"}
diff --git a/public/registry/widgets/github-releases/widget.is b/public/registry/widgets/github-releases/widget.is
index f791702..1aeab2f 100644
--- a/public/registry/widgets/github-releases/widget.is
+++ b/public/registry/widgets/github-releases/widget.is
@@ -64,7 +64,10 @@ var firstRun = true
"Accept": "application/vnd.github+json"
}
})
- if (res.status != 200) {
+ if (res.status == 0) {
+ // 通信断 (DNS 失敗・接続拒否・タイムアウト)。NoteDeck は throw せず status 0 で返す (notedeck#1294)
+ { error: "通信できません" }
+ } elif (res.status != 200) {
{ error: `取得失敗 (HTTP {res.status})` }
} else {
{ releases: Json:parse(res.body) }
diff --git a/public/registry/widgets/ink-new-servers/api.json b/public/registry/widgets/ink-new-servers/api.json
index 7ff98e4..42becf8 100644
--- a/public/registry/widgets/ink-new-servers/api.json
+++ b/public/registry/widgets/ink-new-servers/api.json
@@ -1 +1 @@
-{"type":"widget","data":"/// @ 1.2.1\n/// 新着サーバーウィジェット\n/// servers.misskey.ink の API で最近誕生した Misskey サーバーを表示。\n/// 発見日・ユーザー数・登録受付状態つきで、新しい界隈の発生をデッキで察知できる。\n///\n/// 実装メモ:\n/// - sort=createdAt の created_at は「サービスが初めて発見した日時」(毎日 15:00 UTC の\n/// クロール)。新規サーバーにとっては開設日のよい近似になる\n/// - 1 取得 = 数 KB・認証不要・150 req/min。joinmisskey (5.7MB 一括) と違い\n/// 気軽に再取得できるので、言語切替も都度フェッチする\n/// - データの動きは日次なので自動更新は 6 時間おきで十分\n\nlet API_BASE = \"https://servers.misskey.ink/api/v1/instances?sort=createdAt&order=desc&limit=8\"\nlet SHOW = 5\nlet MODE_KEY = \"ink-new-servers:mode\"\n\n// カラーパレット (crypto / misskey-online と同系統)\nlet COL_ACCENT = \"86b300\" // Misskey 緑 (登録開放)\nlet COL_DOWN = \"ea3943\" // エラー\nlet COL_DIM = \"9aa4b0\" // ラベル・補助\nlet COL_SUB = \"6b7480\" // フッター・招待制\n\n// ─────────────────────────────\n// ヘルパー\n// ─────────────────────────────\n@dflt(v, d) { if (Core:type(v) == \"null\") d else v }\n\n@get(o, k, d) {\n if (Core:type(o) != \"obj\") { d }\n elif (Obj:has(o, k)) { dflt(o[k], d) }\n else { d }\n}\n\n@pad2(n) { if (n < 10) `0{n}` else `{n}` }\n\n/// リンクラベル内の ] はリンクを壊し、* は太字装飾 **{nm}** と干渉するので全角へ\n@safeLabel(s) {\n if (Core:type(s) != \"str\") { \"?\" }\n else { s.replace(\"[\", \"[\").replace(\"]\", \"]\").replace(\"*\", \"*\") }\n}\n\n@trunc(s, n) {\n if (s.len > n) `{s.slice(0, n)}…` else s\n}\n\n/// 発見からの経過日数ラベル\n@ageLabel(ts, now) {\n if (Core:type(ts) != \"num\") { \"?\" }\n else {\n let days = Math:floor((now - ts) / 86400000)\n if (days <= 0) \"今日\"\n elif (days == 1) \"昨日\"\n else `{days}日前`\n }\n}\n\n/// 登録受付状態 (色付き)\n@regLabel(open, email) {\n if (open == true) {\n if (email == true) {\n `$[fg.color={COL_ACCENT} 登録開放] $[fg.color={COL_SUB} ✉要メール]`\n } else {\n `$[fg.color={COL_ACCENT} 登録開放]`\n }\n } else {\n `$[fg.color={COL_SUB} 招待制]`\n }\n}\n\n/// サーバー 1 件 = 2 行 (crypto のカード流儀)。文字列の配列を返す\n@serverRows(item, mode, now) {\n let host = get(item, \"host\", \"?\")\n let nm = trunc(safeLabel(dflt(get(item, \"name\", null), host)), 13)\n var l1 = `🌱 [**{nm}**](https://{host})`\n if (mode == \"all\") {\n let lang = get(item, \"language\", \"\")\n if (lang != \"\") {\n l1 = `{l1} $[fg.color={COL_DIM} {lang}]`\n }\n }\n let users = get(item, \"users_count\", null)\n let usersStr = if (Core:type(users) == \"num\") `{users}` else \"?\"\n let age = ageLabel(get(item, \"created_at\", null), now)\n let reg = regLabel(get(item, \"open_registrations\", false), get(item, \"email_required\", false))\n [\n l1\n `$[fg.color={COL_DIM} {age} ・ {usersStr}人 ・] {reg}`\n ]\n}\n\n/// items から表示行を構築 (is_alive のみ、最大 SHOW 件)\n@buildRows(items, mode, now) {\n var rows = []\n var shown = 0\n each (let item, items) {\n if (shown < SHOW && get(item, \"is_alive\", false) == true) {\n rows = rows.concat(serverRows(item, mode, now))\n shown += 1\n }\n }\n rows\n}\n\n// ---- ここから UI 層 (ハーネス検証はこの行より上の純粋関数のみ) ----\n\nvar lastStatus = 0 // 直近の HTTP ステータス (エラー表示用)\n\n@fetchNew(mode) {\n var url = API_BASE\n if (mode == \"ja\") {\n url = `{url}&language=ja`\n }\n let res = Nd:http(url, {\n method: \"GET\"\n headers: { \"Accept\": \"application/json\" }\n })\n lastStatus = res.status\n if (res.status != 200) { return null }\n let data = Json:parse(res.body)\n get(data, \"items\", null)\n}\n\nvar firstRun = true\nvar hasData = false // 一度でも表示に成功したか\nvar mode = \"ja\"\nlet savedMode = Mk:load(MODE_KEY)\nif (savedMode == \"ja\" || savedMode == \"all\") {\n mode = savedMode\n}\n\n@render() {\n if (firstRun) {\n Ui:render([\n Ui:C:container({\n align: \"center\"\n children: [Ui:C:text({ text: \"新着サーバーを探索中… 🌱\" })]\n })\n ])\n }\n firstRun = false\n\n let items = fetchNew(mode)\n if (Core:type(items) != \"arr\") {\n // 取得失敗。既に表示があればそれを維持してチラつきを防ぐ\n if (hasData) { return null }\n Ui:render([\n Ui:C:container({\n align: \"center\"\n children: [\n Ui:C:mfm({ text: `$[fg.color={COL_DOWN} ⚠️ 新着サーバーを取得できません (HTTP {lastStatus})]` })\n Ui:C:button({ text: \"🔄 再試行\", rounded: true, onClick: @() { render() } })\n ]\n })\n ])\n return null\n }\n hasData = true\n\n let now = Date:now()\n var body = [\n Ui:C:mfm({ text: `🌱 **新着サーバー** $[fg.color={COL_DIM} {pad2(Date:hour(now))}:{pad2(Date:minute(now))}]` })\n ]\n body.push(Ui:C:select({\n items: [\n { text: \"🇯🇵 日本語サーバー\", value: \"ja\" }\n { text: \"🌐 すべての言語\", value: \"all\" }\n ]\n default: mode\n onChange: @(v) {\n mode = v\n Mk:save(MODE_KEY, v)\n render()\n }\n }))\n\n let rows = buildRows(items, mode, now)\n if (rows.len == 0) {\n body.push(Ui:C:mfm({ text: `$[fg.color={COL_DIM} 新着サーバーはありません]` }))\n } else {\n each (let r, rows) {\n body.push(Ui:C:mfm({ text: r }))\n }\n }\n\n body.push(Ui:C:mfm({ text: `$[fg.color={COL_SUB} データ: servers.misskey.ink]` }))\n body.push(Ui:C:button({ text: \"🔄 更新\", rounded: true, onClick: @() { render() } }))\n Ui:render([\n Ui:C:container({\n children: body\n })\n ])\n}\n\n// データは毎日 15:00 UTC 前後に更新されるため 6 時間おきで十分\nrender()\nAsync:interval(21600000, render, false)\n"}
+{"type":"widget","data":"/// @ 1.2.1\n/// 新着サーバーウィジェット\n/// servers.misskey.ink の API で最近誕生した Misskey サーバーを表示。\n/// 発見日・ユーザー数・登録受付状態つきで、新しい界隈の発生をデッキで察知できる。\n///\n/// 実装メモ:\n/// - sort=createdAt の created_at は「サービスが初めて発見した日時」(毎日 15:00 UTC の\n/// クロール)。新規サーバーにとっては開設日のよい近似になる\n/// - 1 取得 = 数 KB・認証不要・150 req/min。joinmisskey (5.7MB 一括) と違い\n/// 気軽に再取得できるので、言語切替も都度フェッチする\n/// - データの動きは日次なので自動更新は 6 時間おきで十分\n\nlet API_BASE = \"https://servers.misskey.ink/api/v1/instances?sort=createdAt&order=desc&limit=8\"\nlet SHOW = 5\nlet MODE_KEY = \"ink-new-servers:mode\"\n\n// カラーパレット (crypto / misskey-online と同系統)\nlet COL_ACCENT = \"86b300\" // Misskey 緑 (登録開放)\nlet COL_DOWN = \"ea3943\" // エラー\nlet COL_DIM = \"9aa4b0\" // ラベル・補助\nlet COL_SUB = \"6b7480\" // フッター・招待制\n\n// ─────────────────────────────\n// ヘルパー\n// ─────────────────────────────\n@dflt(v, d) { if (Core:type(v) == \"null\") d else v }\n\n@get(o, k, d) {\n if (Core:type(o) != \"obj\") { d }\n elif (Obj:has(o, k)) { dflt(o[k], d) }\n else { d }\n}\n\n@pad2(n) { if (n < 10) `0{n}` else `{n}` }\n\n/// リンクラベル内の ] はリンクを壊し、* は太字装飾 **{nm}** と干渉するので全角へ\n@safeLabel(s) {\n if (Core:type(s) != \"str\") { \"?\" }\n else { s.replace(\"[\", \"[\").replace(\"]\", \"]\").replace(\"*\", \"*\") }\n}\n\n@trunc(s, n) {\n if (s.len > n) `{s.slice(0, n)}…` else s\n}\n\n/// 発見からの経過日数ラベル\n@ageLabel(ts, now) {\n if (Core:type(ts) != \"num\") { \"?\" }\n else {\n let days = Math:floor((now - ts) / 86400000)\n if (days <= 0) \"今日\"\n elif (days == 1) \"昨日\"\n else `{days}日前`\n }\n}\n\n/// 登録受付状態 (色付き)\n@regLabel(open, email) {\n if (open == true) {\n if (email == true) {\n `$[fg.color={COL_ACCENT} 登録開放] $[fg.color={COL_SUB} ✉要メール]`\n } else {\n `$[fg.color={COL_ACCENT} 登録開放]`\n }\n } else {\n `$[fg.color={COL_SUB} 招待制]`\n }\n}\n\n/// サーバー 1 件 = 2 行 (crypto のカード流儀)。文字列の配列を返す\n@serverRows(item, mode, now) {\n let host = get(item, \"host\", \"?\")\n let nm = trunc(safeLabel(dflt(get(item, \"name\", null), host)), 13)\n var l1 = `🌱 [**{nm}**](https://{host})`\n if (mode == \"all\") {\n let lang = get(item, \"language\", \"\")\n if (lang != \"\") {\n l1 = `{l1} $[fg.color={COL_DIM} {lang}]`\n }\n }\n let users = get(item, \"users_count\", null)\n let usersStr = if (Core:type(users) == \"num\") `{users}` else \"?\"\n let age = ageLabel(get(item, \"created_at\", null), now)\n let reg = regLabel(get(item, \"open_registrations\", false), get(item, \"email_required\", false))\n [\n l1\n `$[fg.color={COL_DIM} {age} ・ {usersStr}人 ・] {reg}`\n ]\n}\n\n/// items から表示行を構築 (is_alive のみ、最大 SHOW 件)\n@buildRows(items, mode, now) {\n var rows = []\n var shown = 0\n each (let item, items) {\n if (shown < SHOW && get(item, \"is_alive\", false) == true) {\n rows = rows.concat(serverRows(item, mode, now))\n shown += 1\n }\n }\n rows\n}\n\n// ---- ここから UI 層 (ハーネス検証はこの行より上の純粋関数のみ) ----\n\nvar lastStatus = 0 // 直近の HTTP ステータス (エラー表示用)\n/// Nd:http の失敗理由。status 0 は通信断 (DNS 失敗・接続拒否・タイムアウト) で、\n/// NoteDeck は throw せず値で返す (notedeck#1294)\n@httpLabel(st) { if (st == 0) { \"通信できません\" } else { `HTTP {st}` } }\n\n@fetchNew(mode) {\n var url = API_BASE\n if (mode == \"ja\") {\n url = `{url}&language=ja`\n }\n let res = Nd:http(url, {\n method: \"GET\"\n headers: { \"Accept\": \"application/json\" }\n })\n lastStatus = res.status\n if (res.status != 200) { return null }\n let data = Json:parse(res.body)\n get(data, \"items\", null)\n}\n\nvar firstRun = true\nvar hasData = false // 一度でも表示に成功したか\nvar mode = \"ja\"\nlet savedMode = Mk:load(MODE_KEY)\nif (savedMode == \"ja\" || savedMode == \"all\") {\n mode = savedMode\n}\n\n@render() {\n if (firstRun) {\n Ui:render([\n Ui:C:container({\n align: \"center\"\n children: [Ui:C:text({ text: \"新着サーバーを探索中… 🌱\" })]\n })\n ])\n }\n firstRun = false\n\n let items = fetchNew(mode)\n if (Core:type(items) != \"arr\") {\n // 取得失敗。既に表示があればそれを維持してチラつきを防ぐ\n if (hasData) { return null }\n Ui:render([\n Ui:C:container({\n align: \"center\"\n children: [\n Ui:C:mfm({ text: `$[fg.color={COL_DOWN} ⚠️ 新着サーバーを取得できません ({httpLabel(lastStatus)})]` })\n Ui:C:button({ text: \"🔄 再試行\", rounded: true, onClick: @() { render() } })\n ]\n })\n ])\n return null\n }\n hasData = true\n\n let now = Date:now()\n var body = [\n Ui:C:mfm({ text: `🌱 **新着サーバー** $[fg.color={COL_DIM} {pad2(Date:hour(now))}:{pad2(Date:minute(now))}]` })\n ]\n body.push(Ui:C:select({\n items: [\n { text: \"🇯🇵 日本語サーバー\", value: \"ja\" }\n { text: \"🌐 すべての言語\", value: \"all\" }\n ]\n default: mode\n onChange: @(v) {\n mode = v\n Mk:save(MODE_KEY, v)\n render()\n }\n }))\n\n let rows = buildRows(items, mode, now)\n if (rows.len == 0) {\n body.push(Ui:C:mfm({ text: `$[fg.color={COL_DIM} 新着サーバーはありません]` }))\n } else {\n each (let r, rows) {\n body.push(Ui:C:mfm({ text: r }))\n }\n }\n\n body.push(Ui:C:mfm({ text: `$[fg.color={COL_SUB} データ: servers.misskey.ink]` }))\n body.push(Ui:C:button({ text: \"🔄 更新\", rounded: true, onClick: @() { render() } }))\n Ui:render([\n Ui:C:container({\n children: body\n })\n ])\n}\n\n// データは毎日 15:00 UTC 前後に更新されるため 6 時間おきで十分\nrender()\nAsync:interval(21600000, render, false)\n"}
diff --git a/public/registry/widgets/ink-new-servers/widget.is b/public/registry/widgets/ink-new-servers/widget.is
index 3806d72..3da1e6b 100644
--- a/public/registry/widgets/ink-new-servers/widget.is
+++ b/public/registry/widgets/ink-new-servers/widget.is
@@ -104,6 +104,9 @@ let COL_SUB = "6b7480" // フッター・招待制
// ---- ここから UI 層 (ハーネス検証はこの行より上の純粋関数のみ) ----
var lastStatus = 0 // 直近の HTTP ステータス (エラー表示用)
+/// Nd:http の失敗理由。status 0 は通信断 (DNS 失敗・接続拒否・タイムアウト) で、
+/// NoteDeck は throw せず値で返す (notedeck#1294)
+@httpLabel(st) { if (st == 0) { "通信できません" } else { `HTTP {st}` } }
@fetchNew(mode) {
var url = API_BASE
@@ -147,7 +150,7 @@ if (savedMode == "ja" || savedMode == "all") {
Ui:C:container({
align: "center"
children: [
- Ui:C:mfm({ text: `$[fg.color={COL_DOWN} ⚠️ 新着サーバーを取得できません (HTTP {lastStatus})]` })
+ Ui:C:mfm({ text: `$[fg.color={COL_DOWN} ⚠️ 新着サーバーを取得できません ({httpLabel(lastStatus)})]` })
Ui:C:button({ text: "🔄 再試行", rounded: true, onClick: @() { render() } })
]
})
diff --git a/public/registry/widgets/joinmisskey-stats/api.json b/public/registry/widgets/joinmisskey-stats/api.json
index c47c7cc..585e099 100644
--- a/public/registry/widgets/joinmisskey-stats/api.json
+++ b/public/registry/widgets/joinmisskey-stats/api.json
@@ -1 +1 @@
-{"type":"widget","data":"/// @ 1.2.1\n/// 連合統計ウィジェット\n/// joinmisskey (instanceapp.misskey.page) のサーバー一覧データで\n/// Misskey 連合全体の統計・サーバーランキング・自サーバーの状態を表示。\n///\n/// 実装メモ:\n/// - instancesInfos は総合スコア (value) 降順ソート済み → 総合ランキングは先頭を取るだけ\n/// - アクティブ top5 と自サーバー順位は 1 回の線形走査 (910件) で同時に計算 (~16k steps)。\n/// AiScript のフルソートは maxStep 予算 (生涯累積 100k) 的に不可\n/// - データは日次更新なので Mk:save に当日分をキャッシュし、再起動時は 5.7MB の再取得を省く。\n/// フル再構築は 1 セッションあたり ~6 回が予算上限 (🔄 の連打はしない想定)\n\nlet API_URL = \"https://instanceapp.misskey.page/instances.json\"\nlet KEY = \"joinmisskey-stats:v2\"\nlet TOP_N = 5\nlet BARS = [\"▱▱▱▱▱▱\", \"▰▱▱▱▱▱\", \"▰▰▱▱▱▱\", \"▰▰▰▱▱▱\", \"▰▰▰▰▱▱\", \"▰▰▰▰▰▱\", \"▰▰▰▰▰▰\"]\n\n// カラーパレット (crypto / misskey-online と同系統)\nlet COL_ACCENT = \"86b300\" // Misskey 緑 (バー・自サーバー名)\nlet COL_UP = \"16c784\" // 上昇トレンド\nlet COL_DOWN = \"ea3943\" // 下降トレンド・エラー\nlet COL_WARN = \"e69500\" // 要更新バッジ背景 (橙)\nlet COL_BADGE_T = \"0b0d12\" // バッジ文字 (濃色・背景とのコントラスト用)\nlet COL_DIM = \"9aa4b0\" // ラベル・補助\nlet COL_SUB = \"6b7480\" // フッター・更新日\n\n// ─────────────────────────────\n// ヘルパー\n// ─────────────────────────────\n@dflt(v, d) { if (Core:type(v) == \"null\") d else v }\n\n@get(o, k, d) {\n if (Core:type(o) != \"obj\") { d }\n elif (Obj:has(o, k)) { dflt(o[k], d) }\n else { d }\n}\n\n/// 数値を日本語の桁表記に (1860726 -> 186.1万, 413774654 -> 41.4億)\n@fmtNum(n) {\n if (Core:type(n) != \"num\") { \"?\" }\n elif (n >= 100000000) { `{Math:round(n / 10000000) / 10}億` }\n elif (n >= 10000) { `{Math:round(n / 1000) / 10}万` }\n else { `{Math:round(n)}` }\n}\n\n/// リンクラベル内の ] はリンクを壊すので全角へ\n@safeLabel(s) {\n if (Core:type(s) != \"str\") { \"?\" }\n else { s.replace(\"[\", \"[\").replace(\"]\", \"]\") }\n}\n\n@trunc(s, n) {\n if (s.len > n) `{s.slice(0, n)}…` else s\n}\n\n@usersOf(inst) {\n get(get(get(get(inst, \"nodeinfo\", {}), \"usage\", {}), \"users\", {}), \"total\", null)\n}\n\n@medal(i) {\n if (i == 0) \"🥇\" elif (i == 1) \"🥈\" elif (i == 2) \"🥉\" else `{i + 1}.`\n}\n\n/// ピル型バッジ (背景色 + 前景濃色)\n@badge(bg, label) {\n `$[bg.color={bg} $[fg.color={COL_BADGE_T} {label} ]]`\n}\n\n/// v/max を 6 段階のバーに (文字列はプリビルトなのでループなし)。\n/// 1 位が突出していると 2 位以下が全部空になるので √ スケール + 最低 1 目盛り\n@bar(v, max) {\n if (Core:type(v) != \"num\" || Core:type(max) != \"num\" || max <= 0) {\n BARS[0]\n } else {\n var idx = Math:round(Math:sqrt(v / max) * 6)\n if (idx < 1 && v > 0) { idx = 1 }\n if (idx < 0) { idx = 0 }\n if (idx > 6) { idx = 6 }\n BARS[idx]\n }\n}\n\n/// 昨日のアクティブ数と 15 日平均の差をトレンド表示に\n@trendLabel(dy, avg) {\n if (Core:type(dy) != \"num\" || Core:type(avg) != \"num\" || avg <= 0) {\n \"\"\n } else {\n let pct = Math:round((dy - avg) / avg * 100)\n if (pct >= 3) { `$[fg.color={COL_UP} ▲{pct}%]` }\n elif (pct <= -3) { `$[fg.color={COL_DOWN} ▼{Math:abs(pct)}%]` }\n elif (pct >= 0) { `$[fg.color={COL_DIM} +{pct}%]` }\n else { `$[fg.color={COL_DIM} -{Math:abs(pct)}%]` }\n }\n}\n\n/// 降順 top5 リストへの挿入 (要素は {i, dru})\n@insTop(arr, item) {\n var res = []\n var placed = false\n each (let t, arr) {\n if (!placed && item.dru > t.dru) {\n res.push(item)\n placed = true\n }\n res.push(t)\n }\n if (!placed) { res.push(item) }\n if (res.len > TOP_N) res.slice(0, TOP_N) else res\n}\n\n@todayStr() {\n let n = Date:now()\n `{Date:year(n)}-{Date:month(n)}-{Date:day(n)}`\n}\n\n/// ランキング 1 行: メダル + サーバー名リンク + バーと数値\n@rankRow(rank, inst, barStr, numLabel) {\n `{medal(rank)} [{trunc(safeLabel(dflt(inst.name, inst.url)), 11)}](https://{inst.url}) $[fg.color={COL_ACCENT} {barStr}] $[fg.color={COL_DIM} {numLabel}]`\n}\n\n/// 取得データから表示用モデル (文字列のみ) を構築。Mk:save でそのままキャッシュできる\n@buildModel(data, host) {\n let st = data.stats\n let infos = data.instancesInfos\n let total = infos.len\n\n // 1 回の走査でアクティブ top5 と自サーバー位置を同時に取る。\n // 5 位の値を minDru に持ち、per-iter の比較を 1 回に抑える (910 件 × 毎回 len/index 参照は steps が嵩む)\n var topA = []\n var minDru = -1\n var myIdx = -1\n var i = 0\n each (let inst, infos) {\n if (inst.dru15 > minDru) {\n topA = insTop(topA, { i: i, dru: inst.dru15 })\n if (topA.len >= TOP_N) {\n minDru = topA[topA.len - 1].dru\n }\n }\n if (myIdx < 0 && inst.url == host) {\n myIdx = i\n }\n i += 1\n }\n\n // ヘッダー: タイトル行にデータ更新日を統合 (crypto の流儀)\n var dateLabel = \"\"\n let ts = Date:parse(get(data, \"date\", \"\"))\n if (Core:type(ts) == \"num\") {\n dateLabel = ` $[fg.color={COL_DIM} {Date:month(ts)}/{Date:day(ts)}]`\n }\n var header = [\n `🌌 **Misskey 連合**{dateLabel}`\n `サーバー **{fmtNum(st.instancesCount)}** ・ ユーザー **{fmtNum(st.usersCount)}**`\n ]\n var actLine = `ノート {fmtNum(st.notesCount)} ・ アクティブ {fmtNum(st.dru15)}/日`\n let gtl = trendLabel(st.druYesterday, st.dru15)\n if (gtl != \"\") {\n actLine = `{actLine} {gtl}`\n }\n header.push(`$[fg.color={COL_DIM} {actLine}]`)\n\n // 総合ランキング (value 順 = 配列順)。バーと数値はユーザー数\n let cnt = if (total < TOP_N) total else TOP_N\n var users5 = []\n var maxUsers = 0\n for (let j, cnt) {\n let u = usersOf(infos[j])\n users5.push(u)\n if (Core:type(u) == \"num\" && u > maxUsers) {\n maxUsers = u\n }\n }\n var scoreRows = []\n for (let j, cnt) {\n scoreRows.push(rankRow(j, infos[j], bar(users5[j], maxUsers), `{fmtNum(users5[j])}人`))\n }\n\n // アクティブランキング。バーと数値は dru15 (日次アクティブユーザーの 15 日平均)\n var activeRows = []\n let maxDru = if (topA.len > 0) topA[0].dru else 0\n var k = 0\n each (let t, topA) {\n activeRows.push(rankRow(k, infos[t.i], bar(t.dru, maxDru), `{fmtNum(t.dru)}/日`))\n k += 1\n }\n\n // 自サーバーカード\n var mine = []\n if (host != \"\") {\n if (myIdx >= 0) {\n let m = infos[myIdx]\n mine.push(`📍 $[fg.color={COL_ACCENT} **{trunc(safeLabel(dflt(m.name, host)), 14)}**] $[fg.color={COL_DIM} #{myIdx + 1} / {total}]`)\n var l2 = `アクティブ {fmtNum(m.dru15)}/日 ・ {fmtNum(usersOf(m))}人`\n let mtl = trendLabel(m.druYesterday, m.dru15)\n if (mtl != \"\") {\n l2 = `$[fg.color={COL_DIM} {l2}] {mtl}`\n } else {\n l2 = `$[fg.color={COL_DIM} {l2}]`\n }\n mine.push(`{l2}`)\n // バージョンと最新安定版チェック (フォークは \"2026.6.0-yami-…\" 形式なので前方一致で判定)\n let soft = get(get(m, \"nodeinfo\", {}), \"software\", {})\n let sname = get(soft, \"name\", \"?\")\n let ver = get(soft, \"version\", \"?\")\n var vline = `$[fg.color={COL_DIM} {sname} v{trunc(ver, 20)}]`\n let latest = get(data, \"latestMisskeyStableVersion\", \"\")\n if (latest != \"\" && Core:type(ver) == \"str\") {\n if (ver.index_of(latest) == 0) {\n vline = `{vline} {badge(COL_ACCENT, \"✓ 最新\")}`\n } elif (sname == \"misskey\") {\n vline = `{vline} {badge(COL_WARN, `⚠ {latest}`)}`\n }\n }\n mine.push(`{vline}`)\n } else {\n mine.push(`$[fg.color={COL_DIM} 📍 {host} は joinmisskey 未掲載]`)\n }\n }\n\n {\n day: todayStr()\n host: host\n header: header\n scoreRows: scoreRows\n activeRows: activeRows\n mine: mine\n }\n}\n\n// ---- ここから UI 層 (ハーネス検証はこの行より上の純粋関数のみ) ----\n\n@myHost() {\n if (exists SERVER_URL && Core:type(SERVER_URL) == \"str\" && SERVER_URL != \"\") {\n SERVER_URL.replace(\"https://\", \"\").replace(\"http://\", \"\").replace(\"/\", \"\")\n } else {\n null\n }\n}\n\n@fetchInstances() {\n let res = Nd:http(API_URL, {\n method: \"GET\"\n headers: { \"Accept\": \"application/json\", \"Accept-Encoding\": \"identity\" }\n timeoutMs: 60000\n })\n if (res.status != 200) { return null }\n let data = Json:parse(res.body)\n if (Core:type(data) != \"obj\") null else data\n}\n\nvar hostG = \"\"\nvar model = null\nvar mode = \"active\"\n\n@saveModel() {\n model.mode = mode\n Mk:save(KEY, model)\n}\n\n@loadRemote() {\n let data = fetchInstances()\n if (Core:type(data) != \"null\") {\n model = buildModel(data, hostG)\n saveModel()\n }\n}\n\n@renderLoading() {\n Ui:render([\n Ui:C:container({\n align: \"center\"\n children: [Ui:C:text({ text: \"連合データを取得中… 🌌\" })]\n })\n ])\n}\n\n@renderError() {\n Ui:render([\n Ui:C:container({\n align: \"center\"\n children: [\n Ui:C:mfm({ text: `$[fg.color={COL_DOWN} ⚠️ 連合データを取得できません]` })\n Ui:C:button({ text: \"🔄 再試行\", rounded: true, onClick: @() { refresh() } })\n ]\n })\n ])\n}\n\n@uiRender() {\n var body = []\n each (let l, model.header) {\n body.push(Ui:C:mfm({ text: l }))\n }\n body.push(Ui:C:select({\n items: [\n { text: \"🔥 アクティブ (人/日)\", value: \"active\" }\n { text: \"🏆 総合ランキング\", value: \"score\" }\n ]\n default: mode\n onChange: @(v) {\n mode = v\n saveModel()\n uiRender()\n }\n }))\n let rows = if (mode == \"active\") model.activeRows else model.scoreRows\n each (let r, rows) {\n body.push(Ui:C:mfm({ text: r }))\n }\n each (let l, model.mine) {\n body.push(Ui:C:mfm({ text: l }))\n }\n body.push(Ui:C:mfm({ text: `$[fg.color={COL_SUB} データ: joinmisskey]` }))\n body.push(Ui:C:button({ text: \"🔄 更新\", rounded: true, onClick: @() { refresh() } }))\n Ui:render([\n Ui:C:container({\n children: body\n })\n ])\n}\n\n@refresh() {\n // 表示があるうちはローディングに切り替えず、取得失敗時も前回表示を維持する\n // (チラつき防止。crypto / misskey-online と同じ流儀)\n if (model == null) { renderLoading() }\n loadRemote()\n if (model == null) { renderError() } else { uiRender() }\n}\n\n// ─────────────────────────────\n// 起動: 当日キャッシュがあれば 5.7MB の再取得をスキップ\n// ─────────────────────────────\nlet h = myHost()\nif (h != null) {\n hostG = h\n}\nlet cached = Mk:load(KEY)\nif (Core:type(cached) == \"obj\" && cached.day == todayStr() && cached.host == hostG) {\n model = cached\n if (Core:type(cached.mode) == \"str\") {\n mode = cached.mode\n }\n uiRender()\n} else {\n refresh()\n}\n"}
+{"type":"widget","data":"/// @ 1.2.1\n/// 連合統計ウィジェット\n/// joinmisskey (instanceapp.misskey.page) のサーバー一覧データで\n/// Misskey 連合全体の統計・サーバーランキング・自サーバーの状態を表示。\n///\n/// 実装メモ:\n/// - instancesInfos は総合スコア (value) 降順ソート済み → 総合ランキングは先頭を取るだけ\n/// - アクティブ top5 と自サーバー順位は 1 回の線形走査 (910件) で同時に計算 (~16k steps)。\n/// AiScript のフルソートは maxStep 予算 (生涯累積 100k) 的に不可\n/// - データは日次更新なので Mk:save に当日分をキャッシュし、再起動時は 5.7MB の再取得を省く。\n/// フル再構築は 1 セッションあたり ~6 回が予算上限 (🔄 の連打はしない想定)\n\nlet API_URL = \"https://instanceapp.misskey.page/instances.json\"\nlet KEY = \"joinmisskey-stats:v2\"\nlet TOP_N = 5\nlet BARS = [\"▱▱▱▱▱▱\", \"▰▱▱▱▱▱\", \"▰▰▱▱▱▱\", \"▰▰▰▱▱▱\", \"▰▰▰▰▱▱\", \"▰▰▰▰▰▱\", \"▰▰▰▰▰▰\"]\n\n// カラーパレット (crypto / misskey-online と同系統)\nlet COL_ACCENT = \"86b300\" // Misskey 緑 (バー・自サーバー名)\nlet COL_UP = \"16c784\" // 上昇トレンド\nlet COL_DOWN = \"ea3943\" // 下降トレンド・エラー\nlet COL_WARN = \"e69500\" // 要更新バッジ背景 (橙)\nlet COL_BADGE_T = \"0b0d12\" // バッジ文字 (濃色・背景とのコントラスト用)\nlet COL_DIM = \"9aa4b0\" // ラベル・補助\nlet COL_SUB = \"6b7480\" // フッター・更新日\n\n// ─────────────────────────────\n// ヘルパー\n// ─────────────────────────────\n@dflt(v, d) { if (Core:type(v) == \"null\") d else v }\n\n@get(o, k, d) {\n if (Core:type(o) != \"obj\") { d }\n elif (Obj:has(o, k)) { dflt(o[k], d) }\n else { d }\n}\n\n/// 数値を日本語の桁表記に (1860726 -> 186.1万, 413774654 -> 41.4億)\n@fmtNum(n) {\n if (Core:type(n) != \"num\") { \"?\" }\n elif (n >= 100000000) { `{Math:round(n / 10000000) / 10}億` }\n elif (n >= 10000) { `{Math:round(n / 1000) / 10}万` }\n else { `{Math:round(n)}` }\n}\n\n/// リンクラベル内の ] はリンクを壊すので全角へ\n@safeLabel(s) {\n if (Core:type(s) != \"str\") { \"?\" }\n else { s.replace(\"[\", \"[\").replace(\"]\", \"]\") }\n}\n\n@trunc(s, n) {\n if (s.len > n) `{s.slice(0, n)}…` else s\n}\n\n@usersOf(inst) {\n get(get(get(get(inst, \"nodeinfo\", {}), \"usage\", {}), \"users\", {}), \"total\", null)\n}\n\n@medal(i) {\n if (i == 0) \"🥇\" elif (i == 1) \"🥈\" elif (i == 2) \"🥉\" else `{i + 1}.`\n}\n\n/// ピル型バッジ (背景色 + 前景濃色)\n@badge(bg, label) {\n `$[bg.color={bg} $[fg.color={COL_BADGE_T} {label} ]]`\n}\n\n/// v/max を 6 段階のバーに (文字列はプリビルトなのでループなし)。\n/// 1 位が突出していると 2 位以下が全部空になるので √ スケール + 最低 1 目盛り\n@bar(v, max) {\n if (Core:type(v) != \"num\" || Core:type(max) != \"num\" || max <= 0) {\n BARS[0]\n } else {\n var idx = Math:round(Math:sqrt(v / max) * 6)\n if (idx < 1 && v > 0) { idx = 1 }\n if (idx < 0) { idx = 0 }\n if (idx > 6) { idx = 6 }\n BARS[idx]\n }\n}\n\n/// 昨日のアクティブ数と 15 日平均の差をトレンド表示に\n@trendLabel(dy, avg) {\n if (Core:type(dy) != \"num\" || Core:type(avg) != \"num\" || avg <= 0) {\n \"\"\n } else {\n let pct = Math:round((dy - avg) / avg * 100)\n if (pct >= 3) { `$[fg.color={COL_UP} ▲{pct}%]` }\n elif (pct <= -3) { `$[fg.color={COL_DOWN} ▼{Math:abs(pct)}%]` }\n elif (pct >= 0) { `$[fg.color={COL_DIM} +{pct}%]` }\n else { `$[fg.color={COL_DIM} -{Math:abs(pct)}%]` }\n }\n}\n\n/// 降順 top5 リストへの挿入 (要素は {i, dru})\n@insTop(arr, item) {\n var res = []\n var placed = false\n each (let t, arr) {\n if (!placed && item.dru > t.dru) {\n res.push(item)\n placed = true\n }\n res.push(t)\n }\n if (!placed) { res.push(item) }\n if (res.len > TOP_N) res.slice(0, TOP_N) else res\n}\n\n@todayStr() {\n let n = Date:now()\n `{Date:year(n)}-{Date:month(n)}-{Date:day(n)}`\n}\n\n/// ランキング 1 行: メダル + サーバー名リンク + バーと数値\n@rankRow(rank, inst, barStr, numLabel) {\n `{medal(rank)} [{trunc(safeLabel(dflt(inst.name, inst.url)), 11)}](https://{inst.url}) $[fg.color={COL_ACCENT} {barStr}] $[fg.color={COL_DIM} {numLabel}]`\n}\n\n/// 取得データから表示用モデル (文字列のみ) を構築。Mk:save でそのままキャッシュできる\n@buildModel(data, host) {\n let st = data.stats\n let infos = data.instancesInfos\n let total = infos.len\n\n // 1 回の走査でアクティブ top5 と自サーバー位置を同時に取る。\n // 5 位の値を minDru に持ち、per-iter の比較を 1 回に抑える (910 件 × 毎回 len/index 参照は steps が嵩む)\n var topA = []\n var minDru = -1\n var myIdx = -1\n var i = 0\n each (let inst, infos) {\n if (inst.dru15 > minDru) {\n topA = insTop(topA, { i: i, dru: inst.dru15 })\n if (topA.len >= TOP_N) {\n minDru = topA[topA.len - 1].dru\n }\n }\n if (myIdx < 0 && inst.url == host) {\n myIdx = i\n }\n i += 1\n }\n\n // ヘッダー: タイトル行にデータ更新日を統合 (crypto の流儀)\n var dateLabel = \"\"\n let ts = Date:parse(get(data, \"date\", \"\"))\n if (Core:type(ts) == \"num\") {\n dateLabel = ` $[fg.color={COL_DIM} {Date:month(ts)}/{Date:day(ts)}]`\n }\n var header = [\n `🌌 **Misskey 連合**{dateLabel}`\n `サーバー **{fmtNum(st.instancesCount)}** ・ ユーザー **{fmtNum(st.usersCount)}**`\n ]\n var actLine = `ノート {fmtNum(st.notesCount)} ・ アクティブ {fmtNum(st.dru15)}/日`\n let gtl = trendLabel(st.druYesterday, st.dru15)\n if (gtl != \"\") {\n actLine = `{actLine} {gtl}`\n }\n header.push(`$[fg.color={COL_DIM} {actLine}]`)\n\n // 総合ランキング (value 順 = 配列順)。バーと数値はユーザー数\n let cnt = if (total < TOP_N) total else TOP_N\n var users5 = []\n var maxUsers = 0\n for (let j, cnt) {\n let u = usersOf(infos[j])\n users5.push(u)\n if (Core:type(u) == \"num\" && u > maxUsers) {\n maxUsers = u\n }\n }\n var scoreRows = []\n for (let j, cnt) {\n scoreRows.push(rankRow(j, infos[j], bar(users5[j], maxUsers), `{fmtNum(users5[j])}人`))\n }\n\n // アクティブランキング。バーと数値は dru15 (日次アクティブユーザーの 15 日平均)\n var activeRows = []\n let maxDru = if (topA.len > 0) topA[0].dru else 0\n var k = 0\n each (let t, topA) {\n activeRows.push(rankRow(k, infos[t.i], bar(t.dru, maxDru), `{fmtNum(t.dru)}/日`))\n k += 1\n }\n\n // 自サーバーカード\n var mine = []\n if (host != \"\") {\n if (myIdx >= 0) {\n let m = infos[myIdx]\n mine.push(`📍 $[fg.color={COL_ACCENT} **{trunc(safeLabel(dflt(m.name, host)), 14)}**] $[fg.color={COL_DIM} #{myIdx + 1} / {total}]`)\n var l2 = `アクティブ {fmtNum(m.dru15)}/日 ・ {fmtNum(usersOf(m))}人`\n let mtl = trendLabel(m.druYesterday, m.dru15)\n if (mtl != \"\") {\n l2 = `$[fg.color={COL_DIM} {l2}] {mtl}`\n } else {\n l2 = `$[fg.color={COL_DIM} {l2}]`\n }\n mine.push(`{l2}`)\n // バージョンと最新安定版チェック (フォークは \"2026.6.0-yami-…\" 形式なので前方一致で判定)\n let soft = get(get(m, \"nodeinfo\", {}), \"software\", {})\n let sname = get(soft, \"name\", \"?\")\n let ver = get(soft, \"version\", \"?\")\n var vline = `$[fg.color={COL_DIM} {sname} v{trunc(ver, 20)}]`\n let latest = get(data, \"latestMisskeyStableVersion\", \"\")\n if (latest != \"\" && Core:type(ver) == \"str\") {\n if (ver.index_of(latest) == 0) {\n vline = `{vline} {badge(COL_ACCENT, \"✓ 最新\")}`\n } elif (sname == \"misskey\") {\n vline = `{vline} {badge(COL_WARN, `⚠ {latest}`)}`\n }\n }\n mine.push(`{vline}`)\n } else {\n mine.push(`$[fg.color={COL_DIM} 📍 {host} は joinmisskey 未掲載]`)\n }\n }\n\n {\n day: todayStr()\n host: host\n header: header\n scoreRows: scoreRows\n activeRows: activeRows\n mine: mine\n }\n}\n\n// ---- ここから UI 層 (ハーネス検証はこの行より上の純粋関数のみ) ----\n\n@myHost() {\n if (exists SERVER_URL && Core:type(SERVER_URL) == \"str\" && SERVER_URL != \"\") {\n SERVER_URL.replace(\"https://\", \"\").replace(\"http://\", \"\").replace(\"/\", \"\")\n } else {\n null\n }\n}\n\nvar lastStatus = 0 // 直近の HTTP ステータス (エラー表示用。0 = 通信断、notedeck#1294)\n\n@fetchInstances() {\n let res = Nd:http(API_URL, {\n method: \"GET\"\n headers: { \"Accept\": \"application/json\", \"Accept-Encoding\": \"identity\" }\n timeoutMs: 60000\n })\n lastStatus = res.status\n if (res.status != 200) { return null }\n let data = Json:parse(res.body)\n if (Core:type(data) != \"obj\") null else data\n}\n\nvar hostG = \"\"\nvar model = null\nvar mode = \"active\"\n\n@saveModel() {\n model.mode = mode\n Mk:save(KEY, model)\n}\n\n@loadRemote() {\n let data = fetchInstances()\n if (Core:type(data) != \"null\") {\n model = buildModel(data, hostG)\n saveModel()\n }\n}\n\n@renderLoading() {\n Ui:render([\n Ui:C:container({\n align: \"center\"\n children: [Ui:C:text({ text: \"連合データを取得中… 🌌\" })]\n })\n ])\n}\n\n@renderError() {\n let reason = if (lastStatus == 0) { \" (通信できません)\" } elif (lastStatus != 200) { ` (HTTP {lastStatus})` } else { \"\" }\n Ui:render([\n Ui:C:container({\n align: \"center\"\n children: [\n Ui:C:mfm({ text: `$[fg.color={COL_DOWN} ⚠️ 連合データを取得できません{reason}]` })\n Ui:C:button({ text: \"🔄 再試行\", rounded: true, onClick: @() { refresh() } })\n ]\n })\n ])\n}\n\n@uiRender() {\n var body = []\n each (let l, model.header) {\n body.push(Ui:C:mfm({ text: l }))\n }\n body.push(Ui:C:select({\n items: [\n { text: \"🔥 アクティブ (人/日)\", value: \"active\" }\n { text: \"🏆 総合ランキング\", value: \"score\" }\n ]\n default: mode\n onChange: @(v) {\n mode = v\n saveModel()\n uiRender()\n }\n }))\n let rows = if (mode == \"active\") model.activeRows else model.scoreRows\n each (let r, rows) {\n body.push(Ui:C:mfm({ text: r }))\n }\n each (let l, model.mine) {\n body.push(Ui:C:mfm({ text: l }))\n }\n body.push(Ui:C:mfm({ text: `$[fg.color={COL_SUB} データ: joinmisskey]` }))\n body.push(Ui:C:button({ text: \"🔄 更新\", rounded: true, onClick: @() { refresh() } }))\n Ui:render([\n Ui:C:container({\n children: body\n })\n ])\n}\n\n@refresh() {\n // 表示があるうちはローディングに切り替えず、取得失敗時も前回表示を維持する\n // (チラつき防止。crypto / misskey-online と同じ流儀)\n if (model == null) { renderLoading() }\n loadRemote()\n if (model == null) { renderError() } else { uiRender() }\n}\n\n// ─────────────────────────────\n// 起動: 当日キャッシュがあれば 5.7MB の再取得をスキップ\n// ─────────────────────────────\nlet h = myHost()\nif (h != null) {\n hostG = h\n}\nlet cached = Mk:load(KEY)\nif (Core:type(cached) == \"obj\" && cached.day == todayStr() && cached.host == hostG) {\n model = cached\n if (Core:type(cached.mode) == \"str\") {\n mode = cached.mode\n }\n uiRender()\n} else {\n refresh()\n}\n"}
diff --git a/public/registry/widgets/joinmisskey-stats/widget.is b/public/registry/widgets/joinmisskey-stats/widget.is
index 9ea13e2..efcf578 100644
--- a/public/registry/widgets/joinmisskey-stats/widget.is
+++ b/public/registry/widgets/joinmisskey-stats/widget.is
@@ -238,12 +238,15 @@ let COL_SUB = "6b7480" // フッター・更新日
}
}
+var lastStatus = 0 // 直近の HTTP ステータス (エラー表示用。0 = 通信断、notedeck#1294)
+
@fetchInstances() {
let res = Nd:http(API_URL, {
method: "GET"
headers: { "Accept": "application/json", "Accept-Encoding": "identity" }
timeoutMs: 60000
})
+ lastStatus = res.status
if (res.status != 200) { return null }
let data = Json:parse(res.body)
if (Core:type(data) != "obj") null else data
@@ -276,11 +279,12 @@ var mode = "active"
}
@renderError() {
+ let reason = if (lastStatus == 0) { " (通信できません)" } elif (lastStatus != 200) { ` (HTTP {lastStatus})` } else { "" }
Ui:render([
Ui:C:container({
align: "center"
children: [
- Ui:C:mfm({ text: `$[fg.color={COL_DOWN} ⚠️ 連合データを取得できません]` })
+ Ui:C:mfm({ text: `$[fg.color={COL_DOWN} ⚠️ 連合データを取得できません{reason}]` })
Ui:C:button({ text: "🔄 再試行", rounded: true, onClick: @() { refresh() } })
]
})
diff --git a/public/registry/widgets/listenbrainz/api.json b/public/registry/widgets/listenbrainz/api.json
index 589f2b9..ae3c8c7 100644
--- a/public/registry/widgets/listenbrainz/api.json
+++ b/public/registry/widgets/listenbrainz/api.json
@@ -1 +1 @@
-{"type":"widget","data":"/// @ 1.2.1\n/// ListenBrainz Now Playing ウィジェット\n/// ListenBrainz で今聴いている曲を表示し、ワンタップで #nowplaying 投稿。\n/// userId は ListenBrainz のユーザー名 (Misskey アカウントとは無関係)。\n/// 元ネタ: yamisskey WidgetListenBrainz.vue (lqvp) を AiScript で再実装。\n\n@dflt(v, d) {\n if (Core:type(v) == \"null\") d else v\n}\n\n@get(o, k, d) {\n if (Core:type(o) != \"obj\") {\n d\n } elif (Obj:has(o, k)) {\n dflt(o[k], d)\n } else {\n d\n }\n}\n\nlet DEFAULT_FORMAT = \"{artist_name} - {track_name} ({media_player}/{music_service_name}/{client}) {url} #nowplaying\"\n\nvar userId = dflt(Mk:load(\"userId\"), \"\")\nvar noteFormat = dflt(Mk:load(\"noteFormat\"), DEFAULT_FORMAT)\nvar firstRun = true\n\n@formatNote(track) {\n let info = get(track, \"additional_info\", {})\n var s = noteFormat\n s = s.replace(\"{artist_name}\", get(track, \"artist_name\", \"\"))\n s = s.replace(\"{track_name}\", get(track, \"track_name\", \"\"))\n s = s.replace(\"{media_player}\", get(info, \"media_player\", \"\"))\n s = s.replace(\"{music_service_name}\", get(info, \"music_service_name\", \"\"))\n s = s.replace(\"{url}\", get(info, \"origin_url\", \"\"))\n s = s.replace(\"{client}\", get(info, \"submission_client\", \"\"))\n s\n}\n\n@configFolder() {\n Ui:C:folder({\n title: \"設定\"\n opened: (userId == \"\")\n children: [\n Ui:C:text({ text: \"ListenBrainz ユーザー名\" })\n Ui:C:textInput({\n default: userId\n onInput: @(v) {\n userId = v\n Mk:save(\"userId\", v)\n }\n })\n Ui:C:text({ text: \"投稿フォーマット\" })\n Ui:C:textarea({\n default: noteFormat\n onInput: @(v) {\n noteFormat = v\n Mk:save(\"noteFormat\", v)\n }\n })\n ]\n })\n}\n\n@render() {\n if (userId == \"\") {\n Ui:render([\n Ui:C:container({\n children: [\n Ui:C:text({ text: \"ListenBrainz のユーザー名を設定してください\" })\n configFolder()\n ]\n })\n ])\n } else {\n if (firstRun) {\n Ui:render([\n Ui:C:container({\n align: \"center\"\n children: [ Ui:C:text({ text: \"読み込み中…\" }) ]\n })\n ])\n }\n firstRun = false\n\n let url = `https://api.listenbrainz.org/1/user/{userId}/playing-now`\n let res = Nd:http(url, { method: \"GET\" })\n let data = Json:parse(res.body)\n let payload = get(data, \"payload\", {})\n let listens = get(payload, \"listens\", [])\n\n var body = []\n if (listens.len == 0) {\n body = [ Ui:C:text({ text: \"再生中の曲はありません\" }) ]\n } else {\n let track = listens[0].track_metadata\n let note = formatNote(track)\n body = [\n Ui:C:mfm({ text: note })\n Ui:C:postFormButton({\n text: \"ノート\"\n primary: true\n form: { text: note }\n })\n ]\n }\n\n Ui:render([\n Ui:C:container({\n align: \"center\"\n children: body.concat([ configFolder() ])\n })\n ])\n }\n}\n\nrender()\nAsync:interval(60000, render, false)\n"}
+{"type":"widget","data":"/// @ 1.2.1\n/// ListenBrainz Now Playing ウィジェット\n/// ListenBrainz で今聴いている曲を表示し、ワンタップで #nowplaying 投稿。\n/// userId は ListenBrainz のユーザー名 (Misskey アカウントとは無関係)。\n/// 元ネタ: yamisskey WidgetListenBrainz.vue (lqvp) を AiScript で再実装。\n\n@dflt(v, d) {\n if (Core:type(v) == \"null\") d else v\n}\n\n@get(o, k, d) {\n if (Core:type(o) != \"obj\") {\n d\n } elif (Obj:has(o, k)) {\n dflt(o[k], d)\n } else {\n d\n }\n}\n\nlet DEFAULT_FORMAT = \"{artist_name} - {track_name} ({media_player}/{music_service_name}/{client}) {url} #nowplaying\"\n\nvar userId = dflt(Mk:load(\"userId\"), \"\")\nvar noteFormat = dflt(Mk:load(\"noteFormat\"), DEFAULT_FORMAT)\nvar firstRun = true\n\n@formatNote(track) {\n let info = get(track, \"additional_info\", {})\n var s = noteFormat\n s = s.replace(\"{artist_name}\", get(track, \"artist_name\", \"\"))\n s = s.replace(\"{track_name}\", get(track, \"track_name\", \"\"))\n s = s.replace(\"{media_player}\", get(info, \"media_player\", \"\"))\n s = s.replace(\"{music_service_name}\", get(info, \"music_service_name\", \"\"))\n s = s.replace(\"{url}\", get(info, \"origin_url\", \"\"))\n s = s.replace(\"{client}\", get(info, \"submission_client\", \"\"))\n s\n}\n\n@configFolder() {\n Ui:C:folder({\n title: \"設定\"\n opened: (userId == \"\")\n children: [\n Ui:C:text({ text: \"ListenBrainz ユーザー名\" })\n Ui:C:textInput({\n default: userId\n onInput: @(v) {\n userId = v\n Mk:save(\"userId\", v)\n }\n })\n Ui:C:text({ text: \"投稿フォーマット\" })\n Ui:C:textarea({\n default: noteFormat\n onInput: @(v) {\n noteFormat = v\n Mk:save(\"noteFormat\", v)\n }\n })\n ]\n })\n}\n\n@render() {\n if (userId == \"\") {\n Ui:render([\n Ui:C:container({\n children: [\n Ui:C:text({ text: \"ListenBrainz のユーザー名を設定してください\" })\n configFolder()\n ]\n })\n ])\n } else {\n if (firstRun) {\n Ui:render([\n Ui:C:container({\n align: \"center\"\n children: [ Ui:C:text({ text: \"読み込み中…\" }) ]\n })\n ])\n }\n firstRun = false\n\n let url = `https://api.listenbrainz.org/1/user/{userId}/playing-now`\n let res = Nd:http(url, { method: \"GET\" })\n // 非 200 (通信断は status 0、notedeck#1294) で Json:parse が例外を投げないよう先に分岐\n let listens = if (res.status != 200) { [] } else { get(get(Json:parse(res.body), \"payload\", {}), \"listens\", []) }\n\n var body = []\n if (res.status != 200) {\n let reason = if (res.status == 0) { \"通信できません\" } else { `HTTP {res.status}` }\n body = [ Ui:C:mfm({ text: `$[fg.color=f74e52 ⚠️ 再生中の曲を取得できません ({reason})]` }) ]\n } elif (listens.len == 0) {\n body = [ Ui:C:text({ text: \"再生中の曲はありません\" }) ]\n } else {\n let track = listens[0].track_metadata\n let note = formatNote(track)\n body = [\n Ui:C:mfm({ text: note })\n Ui:C:postFormButton({\n text: \"ノート\"\n primary: true\n form: { text: note }\n })\n ]\n }\n\n Ui:render([\n Ui:C:container({\n align: \"center\"\n children: body.concat([ configFolder() ])\n })\n ])\n }\n}\n\nrender()\nAsync:interval(60000, render, false)\n"}
diff --git a/public/registry/widgets/listenbrainz/widget.is b/public/registry/widgets/listenbrainz/widget.is
index 2a5be3b..bcbbfa1 100644
--- a/public/registry/widgets/listenbrainz/widget.is
+++ b/public/registry/widgets/listenbrainz/widget.is
@@ -84,12 +84,14 @@ var firstRun = true
let url = `https://api.listenbrainz.org/1/user/{userId}/playing-now`
let res = Nd:http(url, { method: "GET" })
- let data = Json:parse(res.body)
- let payload = get(data, "payload", {})
- let listens = get(payload, "listens", [])
+ // 非 200 (通信断は status 0、notedeck#1294) で Json:parse が例外を投げないよう先に分岐
+ let listens = if (res.status != 200) { [] } else { get(get(Json:parse(res.body), "payload", {}), "listens", []) }
var body = []
- if (listens.len == 0) {
+ if (res.status != 200) {
+ let reason = if (res.status == 0) { "通信できません" } else { `HTTP {res.status}` }
+ body = [ Ui:C:mfm({ text: `$[fg.color=f74e52 ⚠️ 再生中の曲を取得できません ({reason})]` }) ]
+ } elif (listens.len == 0) {
body = [ Ui:C:text({ text: "再生中の曲はありません" }) ]
} else {
let track = listens[0].track_metadata
diff --git a/public/registry/widgets/minecraft/api.json b/public/registry/widgets/minecraft/api.json
index 63b2306..701a674 100644
--- a/public/registry/widgets/minecraft/api.json
+++ b/public/registry/widgets/minecraft/api.json
@@ -1 +1 @@
-{"type":"widget","data":"/// @ 1.2.1\n/// Minecraft サーバーウィジェット\n/// api.mcstatus.io でマイクラ Java サーバーの稼働状況・接続人数・MOTD を表示。API キー不要。\n/// API は NoteDeck サーバーサイド (Nd:http) で叩くため CORS/UA 制約を受けない。\n/// デフォルトは Misskey.io 公式サーバー (mc.misskey.io)。\n/// 別サーバーを見るには下の DEFAULT_HOST を書き換える (例: hypixel.net)。\n\nlet DEFAULT_HOST = \"mc.misskey.io\"\nlet BAR_WIDTH = 12 // 人数ゲージの桁幅\n\nvar lastStatus = 0 // 直近の HTTP ステータス (エラー表示用)\n\n// カラーパレット (Minecraft グラスブロック調のグリーン基調)\nlet COL_ON = \"3ddc84\" // ONLINE バッジ背景\nlet COL_OFF = \"ea3943\" // OFFLINE バッジ背景\nlet COL_BADGE_T = \"06140c\" // バッジ文字 (濃色・背景とのコントラスト用)\nlet COL_GAUGE = \"5dba7d\" // ゲージのバー\nlet COL_GAUGE_BG = \"2b3a30\" // ゲージの空き\nlet COL_TEXT = \"eff2f5\" // 主要数値\nlet COL_DIM = \"9aa4b0\" // ラベル・補助\nlet COL_SUB = \"6b7480\" // MOTD・フッター\nlet COL_ACCENT = \"6cc28a\" // ホスト名\n\n// ─────────────────────────────\n// ヘルパー\n// ─────────────────────────────\n@dflt(v, d) { if (Core:type(v) == \"null\") d else v }\n\n@get(o, k, d) {\n if (Core:type(o) != \"obj\") { d }\n elif (Obj:has(o, k)) { dflt(o[k], d) }\n else { d }\n}\n\n@pad2(n) { if (n < 10) { `0{n}` } else { `{n}` } }\n\n/// 同じ文字を n 個並べた文字列\n@repeatStr(ch, n) {\n var s = \"\"\n if (n > 0) { for (let i, n) { s = `{s}{ch}` } }\n s\n}\n\n/// online/max からブロック文字のゲージを作る (バー色と空き色を分けて返す)\n@gauge(online, mx, w) {\n var filled = 0\n if (mx > 0) { filled = Math:round(online / mx * w) }\n if (filled < 0) { filled = 0 }\n if (filled > w) { filled = w }\n // 1人でもいれば最低1ブロックは点灯させる (視認性)\n if (filled == 0 && online > 0) { filled = 1 }\n let bar = repeatStr(\"█\", filled)\n let rest = repeatStr(\"█\", w - filled)\n `$[fg.color={COL_GAUGE} {bar}]$[fg.color={COL_GAUGE_BG} {rest}]`\n}\n\n/// 収容率 (%)\n@pct(online, mx) {\n if (mx <= 0) { return 0 }\n Math:round(online / mx * 100)\n}\n\n// ─────────────────────────────\n// データ取得\n// ─────────────────────────────\n@fetchStatus(host) {\n let url = `https://api.mcstatus.io/v2/status/java/{host}`\n let res = Nd:http(url, {\n method: \"GET\"\n headers: { \"Accept\": \"application/json\" }\n })\n lastStatus = res.status\n if (res.status != 200) { return null }\n Json:parse(res.body)\n}\n\n// ─────────────────────────────\n// UI 構築\n// ─────────────────────────────\n/// ピル型ステータスバッジ (背景色 + 前景濃色)\n@badge(bg, label) {\n `$[bg.color={bg} $[fg.color={COL_BADGE_T} {label} ]]`\n}\n\n@statusView(data, host) {\n let now = Date:now()\n let clock = `{pad2(Date:hour(now))}:{pad2(Date:minute(now))}`\n\n // ヘッダー: ホスト名 + ステータスピル\n var body = [\n Ui:C:mfm({ text: `$[fg.color={COL_ACCENT} ⛏ **{host}**]` })\n ]\n\n // online は HTTP 200 でも false になり得る (サーバー停止中)\n if (get(data, \"online\", false) != true) {\n body.push(Ui:C:mfm({ text: `{badge(COL_OFF, \"● OFFLINE\")}` }))\n body.push(Ui:C:mfm({ text: `$[fg.color={COL_SUB} サーバーに接続できません]` }))\n body.push(Ui:C:mfm({ text: `$[fg.color={COL_SUB} 🕒 {clock}]` }))\n return body\n }\n\n let players = get(data, \"players\", {})\n let pOn = get(players, \"online\", 0)\n let pMax = get(players, \"max\", 0)\n let ver = get(get(data, \"version\", {}), \"name_clean\", \"?\")\n let motd = get(get(data, \"motd\", {}), \"clean\", \"\")\n\n body.push(Ui:C:mfm({ text: `{badge(COL_ON, \"● ONLINE\")}` }))\n\n // 接続人数を大きく強調 + 収容率\n body.push(Ui:C:mfm({\n text: `$[x2 $[fg.color={COL_TEXT} **{pOn}**]] $[fg.color={COL_DIM} / {pMax}] $[fg.color={COL_DIM} ({pct(pOn, pMax)}%)]`\n }))\n\n // 人数ゲージ\n body.push(Ui:C:mfm({\n text: `{gauge(pOn, pMax, BAR_WIDTH)}`\n font: \"monospace\"\n }))\n\n // バージョン\n body.push(Ui:C:mfm({ text: `$[fg.color={COL_DIM} 🎮 {ver}]` }))\n\n // MOTD (あれば)\n if (motd != \"\") {\n body.push(Ui:C:mfm({ text: `$[fg.color={COL_SUB} {motd}]` }))\n }\n\n // フッター: 更新時刻\n body.push(Ui:C:mfm({ text: `$[fg.color={COL_SUB} 🕒 {clock}]` }))\n body\n}\n\nvar firstRun = true\nvar hasData = false // 一度でも表示に成功したか\n\n@render() {\n let host = DEFAULT_HOST\n\n if (firstRun) {\n Ui:render([\n Ui:C:container({\n align: \"center\"\n children: [Ui:C:text({ text: \"サーバー情報を読み込み中…\" })]\n })\n ])\n }\n firstRun = false\n\n let data = fetchStatus(host)\n if (Core:type(data) != \"obj\") {\n // 通信失敗。既に表示があれば維持してチラつきを防ぐ。\n if (hasData) { return null }\n Ui:render([\n Ui:C:container({\n align: \"center\"\n children: [\n Ui:C:mfm({ text: `$[fg.color=ea3943 ⚠️ サーバー情報を取得できません (HTTP {lastStatus})]` })\n ]\n })\n ])\n return null\n }\n hasData = true\n\n let children = statusView(data, host)\n Ui:render([Ui:C:container({ align: \"center\", children: children })])\n}\n\n// 1 更新 = 1 リクエスト。5 分間隔で十分軽量。\nrender()\nAsync:interval(300000, render, false)\n"}
+{"type":"widget","data":"/// @ 1.2.1\n/// Minecraft サーバーウィジェット\n/// api.mcstatus.io でマイクラ Java サーバーの稼働状況・接続人数・MOTD を表示。API キー不要。\n/// API は NoteDeck サーバーサイド (Nd:http) で叩くため CORS/UA 制約を受けない。\n/// デフォルトは Misskey.io 公式サーバー (mc.misskey.io)。\n/// 別サーバーを見るには下の DEFAULT_HOST を書き換える (例: hypixel.net)。\n\nlet DEFAULT_HOST = \"mc.misskey.io\"\nlet BAR_WIDTH = 12 // 人数ゲージの桁幅\n\nvar lastStatus = 0 // 直近の HTTP ステータス (エラー表示用)\n/// Nd:http の失敗理由。status 0 は通信断 (DNS 失敗・接続拒否・タイムアウト) で、\n/// NoteDeck は throw せず値で返す (notedeck#1294)\n@httpLabel(st) { if (st == 0) { \"通信できません\" } else { `HTTP {st}` } }\n\n// カラーパレット (Minecraft グラスブロック調のグリーン基調)\nlet COL_ON = \"3ddc84\" // ONLINE バッジ背景\nlet COL_OFF = \"ea3943\" // OFFLINE バッジ背景\nlet COL_BADGE_T = \"06140c\" // バッジ文字 (濃色・背景とのコントラスト用)\nlet COL_GAUGE = \"5dba7d\" // ゲージのバー\nlet COL_GAUGE_BG = \"2b3a30\" // ゲージの空き\nlet COL_TEXT = \"eff2f5\" // 主要数値\nlet COL_DIM = \"9aa4b0\" // ラベル・補助\nlet COL_SUB = \"6b7480\" // MOTD・フッター\nlet COL_ACCENT = \"6cc28a\" // ホスト名\n\n// ─────────────────────────────\n// ヘルパー\n// ─────────────────────────────\n@dflt(v, d) { if (Core:type(v) == \"null\") d else v }\n\n@get(o, k, d) {\n if (Core:type(o) != \"obj\") { d }\n elif (Obj:has(o, k)) { dflt(o[k], d) }\n else { d }\n}\n\n@pad2(n) { if (n < 10) { `0{n}` } else { `{n}` } }\n\n/// 同じ文字を n 個並べた文字列\n@repeatStr(ch, n) {\n var s = \"\"\n if (n > 0) { for (let i, n) { s = `{s}{ch}` } }\n s\n}\n\n/// online/max からブロック文字のゲージを作る (バー色と空き色を分けて返す)\n@gauge(online, mx, w) {\n var filled = 0\n if (mx > 0) { filled = Math:round(online / mx * w) }\n if (filled < 0) { filled = 0 }\n if (filled > w) { filled = w }\n // 1人でもいれば最低1ブロックは点灯させる (視認性)\n if (filled == 0 && online > 0) { filled = 1 }\n let bar = repeatStr(\"█\", filled)\n let rest = repeatStr(\"█\", w - filled)\n `$[fg.color={COL_GAUGE} {bar}]$[fg.color={COL_GAUGE_BG} {rest}]`\n}\n\n/// 収容率 (%)\n@pct(online, mx) {\n if (mx <= 0) { return 0 }\n Math:round(online / mx * 100)\n}\n\n// ─────────────────────────────\n// データ取得\n// ─────────────────────────────\n@fetchStatus(host) {\n let url = `https://api.mcstatus.io/v2/status/java/{host}`\n let res = Nd:http(url, {\n method: \"GET\"\n headers: { \"Accept\": \"application/json\" }\n })\n lastStatus = res.status\n if (res.status != 200) { return null }\n Json:parse(res.body)\n}\n\n// ─────────────────────────────\n// UI 構築\n// ─────────────────────────────\n/// ピル型ステータスバッジ (背景色 + 前景濃色)\n@badge(bg, label) {\n `$[bg.color={bg} $[fg.color={COL_BADGE_T} {label} ]]`\n}\n\n@statusView(data, host) {\n let now = Date:now()\n let clock = `{pad2(Date:hour(now))}:{pad2(Date:minute(now))}`\n\n // ヘッダー: ホスト名 + ステータスピル\n var body = [\n Ui:C:mfm({ text: `$[fg.color={COL_ACCENT} ⛏ **{host}**]` })\n ]\n\n // online は HTTP 200 でも false になり得る (サーバー停止中)\n if (get(data, \"online\", false) != true) {\n body.push(Ui:C:mfm({ text: `{badge(COL_OFF, \"● OFFLINE\")}` }))\n body.push(Ui:C:mfm({ text: `$[fg.color={COL_SUB} サーバーに接続できません]` }))\n body.push(Ui:C:mfm({ text: `$[fg.color={COL_SUB} 🕒 {clock}]` }))\n return body\n }\n\n let players = get(data, \"players\", {})\n let pOn = get(players, \"online\", 0)\n let pMax = get(players, \"max\", 0)\n let ver = get(get(data, \"version\", {}), \"name_clean\", \"?\")\n let motd = get(get(data, \"motd\", {}), \"clean\", \"\")\n\n body.push(Ui:C:mfm({ text: `{badge(COL_ON, \"● ONLINE\")}` }))\n\n // 接続人数を大きく強調 + 収容率\n body.push(Ui:C:mfm({\n text: `$[x2 $[fg.color={COL_TEXT} **{pOn}**]] $[fg.color={COL_DIM} / {pMax}] $[fg.color={COL_DIM} ({pct(pOn, pMax)}%)]`\n }))\n\n // 人数ゲージ\n body.push(Ui:C:mfm({\n text: `{gauge(pOn, pMax, BAR_WIDTH)}`\n font: \"monospace\"\n }))\n\n // バージョン\n body.push(Ui:C:mfm({ text: `$[fg.color={COL_DIM} 🎮 {ver}]` }))\n\n // MOTD (あれば)\n if (motd != \"\") {\n body.push(Ui:C:mfm({ text: `$[fg.color={COL_SUB} {motd}]` }))\n }\n\n // フッター: 更新時刻\n body.push(Ui:C:mfm({ text: `$[fg.color={COL_SUB} 🕒 {clock}]` }))\n body\n}\n\nvar firstRun = true\nvar hasData = false // 一度でも表示に成功したか\n\n@render() {\n let host = DEFAULT_HOST\n\n if (firstRun) {\n Ui:render([\n Ui:C:container({\n align: \"center\"\n children: [Ui:C:text({ text: \"サーバー情報を読み込み中…\" })]\n })\n ])\n }\n firstRun = false\n\n let data = fetchStatus(host)\n if (Core:type(data) != \"obj\") {\n // 通信失敗。既に表示があれば維持してチラつきを防ぐ。\n if (hasData) { return null }\n Ui:render([\n Ui:C:container({\n align: \"center\"\n children: [\n Ui:C:mfm({ text: `$[fg.color=ea3943 ⚠️ サーバー情報を取得できません ({httpLabel(lastStatus)})]` })\n ]\n })\n ])\n return null\n }\n hasData = true\n\n let children = statusView(data, host)\n Ui:render([Ui:C:container({ align: \"center\", children: children })])\n}\n\n// 1 更新 = 1 リクエスト。5 分間隔で十分軽量。\nrender()\nAsync:interval(300000, render, false)\n"}
diff --git a/public/registry/widgets/minecraft/widget.is b/public/registry/widgets/minecraft/widget.is
index d52dd74..1d6cbf0 100644
--- a/public/registry/widgets/minecraft/widget.is
+++ b/public/registry/widgets/minecraft/widget.is
@@ -9,6 +9,9 @@ let DEFAULT_HOST = "mc.misskey.io"
let BAR_WIDTH = 12 // 人数ゲージの桁幅
var lastStatus = 0 // 直近の HTTP ステータス (エラー表示用)
+/// Nd:http の失敗理由。status 0 は通信断 (DNS 失敗・接続拒否・タイムアウト) で、
+/// NoteDeck は throw せず値で返す (notedeck#1294)
+@httpLabel(st) { if (st == 0) { "通信できません" } else { `HTTP {st}` } }
// カラーパレット (Minecraft グラスブロック調のグリーン基調)
let COL_ON = "3ddc84" // ONLINE バッジ背景
@@ -155,7 +158,7 @@ var hasData = false // 一度でも表示に成功したか
Ui:C:container({
align: "center"
children: [
- Ui:C:mfm({ text: `$[fg.color=ea3943 ⚠️ サーバー情報を取得できません (HTTP {lastStatus})]` })
+ Ui:C:mfm({ text: `$[fg.color=ea3943 ⚠️ サーバー情報を取得できません ({httpLabel(lastStatus)})]` })
]
})
])
diff --git a/public/registry/widgets/misskey-online/api.json b/public/registry/widgets/misskey-online/api.json
index a410c0e..2164eab 100644
--- a/public/registry/widgets/misskey-online/api.json
+++ b/public/registry/widgets/misskey-online/api.json
@@ -1 +1 @@
-{"type":"widget","data":"/// @ 1.2.1\n/// Misskey オンライン状態ウィジェット\n/// 任意の Misskey ユーザーのオンライン状態 (onlineStatus) と\n/// 投稿数・フォロー/フォロワー数を表示。API キー不要。\n/// API は NoteDeck サーバーサイド (Nd:http) で users/show を POST するため\n/// CORS/UA 制約を受けない。\n///\n/// 対象ユーザーは下の DEFAULT_HOST / DEFAULT_USERNAME を書き換える。\n/// 例: @syuilo@misskey.io → HOST=\"misskey.io\", USERNAME=\"hita1in\"\n/// 注意: リモートユーザーや「オンライン状態を隠す」設定のユーザーは\n/// onlineStatus が \"unknown\" (非公開) になる。\n\nlet DEFAULT_HOST = \"misskey.io\"\nlet DEFAULT_USERNAME = \"hita1in\"\n\nvar lastStatus = 0 // 直近の HTTP ステータス (エラー表示用)\n\n// カラーパレット\nlet COL_ONLINE = \"86b300\" // online バッジ背景 (Misskey 緑)\nlet COL_ACTIVE = \"e69500\" // active バッジ背景 (橙)\nlet COL_OFF = \"80848e\" // offline バッジ背景 (灰)\nlet COL_UNKNOWN = \"a463f2\" // unknown バッジ背景 (紫)\nlet COL_BADGE_T = \"0b0d12\" // バッジ文字 (濃色・背景とのコントラスト用)\nlet COL_ACCENT = \"86b300\" // 表示名 (Misskey 緑)\nlet COL_DIM = \"9aa4b0\" // ラベル・補助\nlet COL_SUB = \"6b7480\" // ハンドル・フッター\n\n// ─────────────────────────────\n// ヘルパー\n// ─────────────────────────────\n@dflt(v, d) { if (Core:type(v) == \"null\") d else v }\n\n@get(o, k, d) {\n if (Core:type(o) != \"obj\") { d }\n elif (Obj:has(o, k)) { dflt(o[k], d) }\n else { d }\n}\n\n@pad2(n) { if (n < 10) { `0{n}` } else { `{n}` } }\n\n/// ピル型ステータスバッジ (背景色 + 前景濃色)\n@badge(bg, label) {\n `$[bg.color={bg} $[fg.color={COL_BADGE_T} {label} ]]`\n}\n\n/// onlineStatus を色・ラベル・アイコンに変換\n@statusInfo(s) {\n if (s == \"online\") { { col: COL_ONLINE, label: \"オンライン\", icon: \"●\" } }\n elif (s == \"active\") { { col: COL_ACTIVE, label: \"アクティブ\", icon: \"●\" } }\n elif (s == \"offline\") { { col: COL_OFF, label: \"オフライン\", icon: \"○\" } }\n elif (s == \"unknown\") { { col: COL_UNKNOWN, label: \"非公開\", icon: \"?\" } }\n else { { col: COL_OFF, label: \"不明\", icon: \"?\" } }\n}\n\n// ─────────────────────────────\n// データ取得\n// ─────────────────────────────\n@fetchUser(host, username) {\n let url = `https://{host}/api/users/show`\n let res = Nd:http(url, {\n method: \"POST\"\n headers: {\n \"Content-Type\": \"application/json\"\n \"Accept\": \"application/json\"\n }\n body: Json:stringify({ username: username })\n })\n lastStatus = res.status\n // 200=成功 / 400・404=ユーザー無し等 (どちらも JSON ボディを持つ) のみパース\n if (res.status != 200 && res.status != 400 && res.status != 404) { return null }\n Json:parse(res.body)\n}\n\n// ─────────────────────────────\n// UI 構築\n// ─────────────────────────────\n@statusView(data, host) {\n let now = Date:now()\n let clock = `{pad2(Date:hour(now))}:{pad2(Date:minute(now))}`\n\n let nameStr = get(data, \"name\", get(data, \"username\", \"ユーザー\"))\n let uname = get(data, \"username\", \"\")\n let st = get(data, \"onlineStatus\", \"unknown\")\n let info = statusInfo(st)\n\n // ヘッダー: 表示名 + ハンドル\n var body = [\n Ui:C:mfm({ text: `$[fg.color={COL_ACCENT} **{nameStr}**]` })\n ]\n if (uname != \"\") {\n body.push(Ui:C:mfm({ text: `$[fg.color={COL_SUB} @{uname}@{host}]` }))\n }\n\n // ステータスピル\n body.push(Ui:C:mfm({ text: `{badge(info.col, `{info.icon} {info.label}`)}` }))\n\n // 統計: 投稿数 / フォロワー / フォロー\n let notes = get(data, \"notesCount\", 0)\n let flwr = get(data, \"followersCount\", 0)\n let flwg = get(data, \"followingCount\", 0)\n body.push(Ui:C:mfm({\n text: `$[fg.color={COL_DIM} 📝 {notes} 👥 {flwr} ➡️ {flwg}]`\n }))\n\n // フッター: 更新時刻\n body.push(Ui:C:mfm({ text: `$[fg.color={COL_SUB} 🕒 {clock}]` }))\n body\n}\n\nvar firstRun = true\nvar hasData = false // 一度でも表示に成功したか\n\n@render() {\n let host = DEFAULT_HOST\n\n if (firstRun) {\n Ui:render([\n Ui:C:container({\n align: \"center\"\n children: [Ui:C:text({ text: \"Misskey の状態を読み込み中…\" })]\n })\n ])\n }\n firstRun = false\n\n let data = fetchUser(host, DEFAULT_USERNAME)\n\n // 通信失敗\n if (Core:type(data) != \"obj\") {\n if (hasData) { return null } // 既に表示があれば維持してチラつきを防ぐ\n Ui:render([\n Ui:C:container({\n align: \"center\"\n children: [\n Ui:C:mfm({ text: `$[fg.color=f23f43 ⚠️ Misskey の状態を取得できません (HTTP {lastStatus})]` })\n ]\n })\n ])\n return null\n }\n\n // users/show はユーザー不在時に { error: {...} } を返す\n if (Obj:has(data, \"error\")) {\n Ui:render([\n Ui:C:container({\n align: \"center\"\n children: [\n Ui:C:mfm({ text: `$[fg.color={COL_DIM} 🔍 ユーザーが見つかりません]` })\n Ui:C:mfm({ text: `$[fg.color={COL_SUB} @{DEFAULT_USERNAME}@{host}]` })\n ]\n })\n ])\n return null\n }\n hasData = true\n\n let children = statusView(data, host)\n Ui:render([Ui:C:container({ align: \"center\", children: children })])\n}\n\n// 1 更新 = 1 リクエスト。60 秒間隔 (hital.in の Misskey ポーリングに合わせる)。\nrender()\nAsync:interval(60000, render, false)\n"}
+{"type":"widget","data":"/// @ 1.2.1\n/// Misskey オンライン状態ウィジェット\n/// 任意の Misskey ユーザーのオンライン状態 (onlineStatus) と\n/// 投稿数・フォロー/フォロワー数を表示。API キー不要。\n/// API は NoteDeck サーバーサイド (Nd:http) で users/show を POST するため\n/// CORS/UA 制約を受けない。\n///\n/// 対象ユーザーは下の DEFAULT_HOST / DEFAULT_USERNAME を書き換える。\n/// 例: @syuilo@misskey.io → HOST=\"misskey.io\", USERNAME=\"hita1in\"\n/// 注意: リモートユーザーや「オンライン状態を隠す」設定のユーザーは\n/// onlineStatus が \"unknown\" (非公開) になる。\n\nlet DEFAULT_HOST = \"misskey.io\"\nlet DEFAULT_USERNAME = \"hita1in\"\n\nvar lastStatus = 0 // 直近の HTTP ステータス (エラー表示用)\n/// Nd:http の失敗理由。status 0 は通信断 (DNS 失敗・接続拒否・タイムアウト) で、\n/// NoteDeck は throw せず値で返す (notedeck#1294)\n@httpLabel(st) { if (st == 0) { \"通信できません\" } else { `HTTP {st}` } }\n\n// カラーパレット\nlet COL_ONLINE = \"86b300\" // online バッジ背景 (Misskey 緑)\nlet COL_ACTIVE = \"e69500\" // active バッジ背景 (橙)\nlet COL_OFF = \"80848e\" // offline バッジ背景 (灰)\nlet COL_UNKNOWN = \"a463f2\" // unknown バッジ背景 (紫)\nlet COL_BADGE_T = \"0b0d12\" // バッジ文字 (濃色・背景とのコントラスト用)\nlet COL_ACCENT = \"86b300\" // 表示名 (Misskey 緑)\nlet COL_DIM = \"9aa4b0\" // ラベル・補助\nlet COL_SUB = \"6b7480\" // ハンドル・フッター\n\n// ─────────────────────────────\n// ヘルパー\n// ─────────────────────────────\n@dflt(v, d) { if (Core:type(v) == \"null\") d else v }\n\n@get(o, k, d) {\n if (Core:type(o) != \"obj\") { d }\n elif (Obj:has(o, k)) { dflt(o[k], d) }\n else { d }\n}\n\n@pad2(n) { if (n < 10) { `0{n}` } else { `{n}` } }\n\n/// ピル型ステータスバッジ (背景色 + 前景濃色)\n@badge(bg, label) {\n `$[bg.color={bg} $[fg.color={COL_BADGE_T} {label} ]]`\n}\n\n/// onlineStatus を色・ラベル・アイコンに変換\n@statusInfo(s) {\n if (s == \"online\") { { col: COL_ONLINE, label: \"オンライン\", icon: \"●\" } }\n elif (s == \"active\") { { col: COL_ACTIVE, label: \"アクティブ\", icon: \"●\" } }\n elif (s == \"offline\") { { col: COL_OFF, label: \"オフライン\", icon: \"○\" } }\n elif (s == \"unknown\") { { col: COL_UNKNOWN, label: \"非公開\", icon: \"?\" } }\n else { { col: COL_OFF, label: \"不明\", icon: \"?\" } }\n}\n\n// ─────────────────────────────\n// データ取得\n// ─────────────────────────────\n@fetchUser(host, username) {\n let url = `https://{host}/api/users/show`\n let res = Nd:http(url, {\n method: \"POST\"\n headers: {\n \"Content-Type\": \"application/json\"\n \"Accept\": \"application/json\"\n }\n body: Json:stringify({ username: username })\n })\n lastStatus = res.status\n // 200=成功 / 400・404=ユーザー無し等 (どちらも JSON ボディを持つ) のみパース\n if (res.status != 200 && res.status != 400 && res.status != 404) { return null }\n Json:parse(res.body)\n}\n\n// ─────────────────────────────\n// UI 構築\n// ─────────────────────────────\n@statusView(data, host) {\n let now = Date:now()\n let clock = `{pad2(Date:hour(now))}:{pad2(Date:minute(now))}`\n\n let nameStr = get(data, \"name\", get(data, \"username\", \"ユーザー\"))\n let uname = get(data, \"username\", \"\")\n let st = get(data, \"onlineStatus\", \"unknown\")\n let info = statusInfo(st)\n\n // ヘッダー: 表示名 + ハンドル\n var body = [\n Ui:C:mfm({ text: `$[fg.color={COL_ACCENT} **{nameStr}**]` })\n ]\n if (uname != \"\") {\n body.push(Ui:C:mfm({ text: `$[fg.color={COL_SUB} @{uname}@{host}]` }))\n }\n\n // ステータスピル\n body.push(Ui:C:mfm({ text: `{badge(info.col, `{info.icon} {info.label}`)}` }))\n\n // 統計: 投稿数 / フォロワー / フォロー\n let notes = get(data, \"notesCount\", 0)\n let flwr = get(data, \"followersCount\", 0)\n let flwg = get(data, \"followingCount\", 0)\n body.push(Ui:C:mfm({\n text: `$[fg.color={COL_DIM} 📝 {notes} 👥 {flwr} ➡️ {flwg}]`\n }))\n\n // フッター: 更新時刻\n body.push(Ui:C:mfm({ text: `$[fg.color={COL_SUB} 🕒 {clock}]` }))\n body\n}\n\nvar firstRun = true\nvar hasData = false // 一度でも表示に成功したか\n\n@render() {\n let host = DEFAULT_HOST\n\n if (firstRun) {\n Ui:render([\n Ui:C:container({\n align: \"center\"\n children: [Ui:C:text({ text: \"Misskey の状態を読み込み中…\" })]\n })\n ])\n }\n firstRun = false\n\n let data = fetchUser(host, DEFAULT_USERNAME)\n\n // 通信失敗\n if (Core:type(data) != \"obj\") {\n if (hasData) { return null } // 既に表示があれば維持してチラつきを防ぐ\n Ui:render([\n Ui:C:container({\n align: \"center\"\n children: [\n Ui:C:mfm({ text: `$[fg.color=f23f43 ⚠️ Misskey の状態を取得できません ({httpLabel(lastStatus)})]` })\n ]\n })\n ])\n return null\n }\n\n // users/show はユーザー不在時に { error: {...} } を返す\n if (Obj:has(data, \"error\")) {\n Ui:render([\n Ui:C:container({\n align: \"center\"\n children: [\n Ui:C:mfm({ text: `$[fg.color={COL_DIM} 🔍 ユーザーが見つかりません]` })\n Ui:C:mfm({ text: `$[fg.color={COL_SUB} @{DEFAULT_USERNAME}@{host}]` })\n ]\n })\n ])\n return null\n }\n hasData = true\n\n let children = statusView(data, host)\n Ui:render([Ui:C:container({ align: \"center\", children: children })])\n}\n\n// 1 更新 = 1 リクエスト。60 秒間隔 (hital.in の Misskey ポーリングに合わせる)。\nrender()\nAsync:interval(60000, render, false)\n"}
diff --git a/public/registry/widgets/misskey-online/widget.is b/public/registry/widgets/misskey-online/widget.is
index 851805c..144a091 100644
--- a/public/registry/widgets/misskey-online/widget.is
+++ b/public/registry/widgets/misskey-online/widget.is
@@ -14,6 +14,9 @@ let DEFAULT_HOST = "misskey.io"
let DEFAULT_USERNAME = "hita1in"
var lastStatus = 0 // 直近の HTTP ステータス (エラー表示用)
+/// Nd:http の失敗理由。status 0 は通信断 (DNS 失敗・接続拒否・タイムアウト) で、
+/// NoteDeck は throw せず値で返す (notedeck#1294)
+@httpLabel(st) { if (st == 0) { "通信できません" } else { `HTTP {st}` } }
// カラーパレット
let COL_ONLINE = "86b300" // online バッジ背景 (Misskey 緑)
@@ -132,7 +135,7 @@ var hasData = false // 一度でも表示に成功したか
Ui:C:container({
align: "center"
children: [
- Ui:C:mfm({ text: `$[fg.color=f23f43 ⚠️ Misskey の状態を取得できません (HTTP {lastStatus})]` })
+ Ui:C:mfm({ text: `$[fg.color=f23f43 ⚠️ Misskey の状態を取得できません ({httpLabel(lastStatus)})]` })
]
})
])
diff --git a/public/registry/widgets/nicolive-watch/api.json b/public/registry/widgets/nicolive-watch/api.json
index bcff7fe..4385216 100644
--- a/public/registry/widgets/nicolive-watch/api.json
+++ b/public/registry/widgets/nicolive-watch/api.json
@@ -1 +1 @@
-{"type":"widget","data":"/// @ 1.2.1\n/// ニコ生 推し配信チェック ウィジェット\n/// 推しのユーザー生放送が「今やっているか」を 3 分ごとに確認し、放送中なら 🔴 タイトル・\n/// 開始からの経過・来場者数を出して、ワンタップで「見てる」ノートを投稿できる。\n///\n/// 設定でユーザー ID を列挙する (最大 5 件、認証不要・ログイン不要)。配信者ごとの放送履歴 API\n/// (live.nicovideo.jp/front/api/v2/user-broadcast-history) を Nd:http で叩き、最新番組の状態を見る。\n/// 終了 1 時間以内は「終了 · タイムシフト」、それ以外は「最後の放送 N 日前」。\n/// ID はプロフィール URL nicovideo.jp/user/<数字> の数字。\n///\n/// 非公式 API なので壊れたら該当行が「取得失敗」になるだけで他の行は巻き込まない。\n/// (ニコニコアカウントのフォロー中を取る API はログイン cookie が要るので扱わない。)\n/// 設計の元ネタ: discord-online (presence の定期取得) / listenbrainz (投稿ボタン)。\n\nlet HISTORY_API = \"https://live.nicovideo.jp/front/api/v2/user-broadcast-history\"\nlet UA = \"misstore-nicolive-watch/1.0 (https://github.com/notedeck-dev/misstore)\"\nlet MAX_IDS = 5\nlet REFRESH_MS = 180000\nlet RECENT_END_MS = 3600000 // 終了からこの時間内は「タイムシフト」案内\n\n// カラーパレット\nlet MUTED = \"8a96a3\"\nlet LINE = \"5a6573\"\nlet LIVE = \"ff4d4d\"\nlet RED = \"ff6961\"\nlet ACCENT = \"ffb300\"\n\n// ─────────────────────────────\n// ヘルパー\n// ─────────────────────────────\n@dflt(v, d) { if (Core:type(v) == \"null\") d else v }\n\n@get(o, k, d) {\n if (Core:type(o) != \"obj\") { d }\n elif (Obj:has(o, k)) { dflt(o[k], d) }\n else { d }\n}\n\n@num(v) {\n if (Core:type(v) == \"num\") { return v }\n if (Core:type(v) != \"str\") { return 0 }\n let n = v.to_num()\n if (Core:type(n) == \"num\") { n } else { 0 }\n}\n\n@trim(s) {\n if (Core:type(s) != \"str\") { return \"\" }\n var a = 0\n var b = s.len\n loop { if (a < b && s.slice(a, a + 1) == \" \") { a = a + 1 } else { break } }\n loop { if (b > a && s.slice(b - 1, b) == \" \") { b = b - 1 } else { break } }\n s.slice(a, b)\n}\n\n@isAllDigits(s) {\n if (s.len == 0) { return false }\n var ok = true\n for (let i, s.len) {\n let c = s.slice(i, i + 1)\n if (Core:type(c.to_num()) != \"num\") { ok = false }\n }\n ok\n}\n\n/// MFM 制御文字を無害化。配信者名・番組名は外部由来なので必ず通す\n@sanitizeText(s) {\n if (Core:type(s) != \"str\") { return \"(不明)\" }\n var txt = s.replace(\"$[\", \"$ \")\n txt = txt.replace(\"]\", \"]\")\n txt = txt.replace(\"[\", \"[\")\n txt = txt.replace(\"(\", \"(\")\n txt = txt.replace(\")\", \")\")\n txt = txt.replace(\"<\", \"<\")\n txt = txt.replace(\">\", \">\")\n txt = txt.replace(\"`\", \"`\")\n txt = txt.replace(\"*\", \"*\")\n txt = txt.replace(\"~\", \"〜\")\n txt = txt.replace(\"\\\\\", \"\\")\n txt = txt.replace(\"@\", \"@\")\n txt = txt.replace(\"#\", \"#\")\n txt = txt.replace(\"://\", \"://\")\n txt\n}\n\n@truncName(s, n) {\n if (s.len > n) { `{s.slice(0, n)}…` } else { s }\n}\n\n/// 桁区切り。1234567 → \"1,234,567\"\n@fmtInt(n) {\n let s = Core:to_str(Math:round(num(n)))\n var out1 = \"\"\n var cnt = 0\n var i = s.len\n loop {\n if (i <= 0) { break }\n out1 = `{s.slice(i - 1, i)}{out1}`\n cnt = cnt + 1\n i = i - 1\n if (cnt % 3 == 0 && i > 0) { out1 = `,{out1}` }\n }\n out1\n}\n\n@ago(ms) {\n let diff = Date:now() - ms\n if (diff < 3600000) { `{Math:max(1, Math:floor(diff / 60000))} 分前` }\n elif (diff < 86400000) { `{Math:floor(diff / 3600000)} 時間前` }\n elif (diff < 2592000000) { `{Math:floor(diff / 86400000)} 日前` }\n elif (diff < 31536000000) { `{Math:floor(diff / 2592000000)} か月前` }\n else { `{Math:floor(diff / 31536000000)} 年前` }\n}\n\n@divider() {\n Ui:C:mfm({ text: `$[fg.color={LINE} ───────────────]` })\n}\n\n@watchUrl(lv) { `https://live.nicovideo.jp/watch/{lv}` }\n\n// ─────────────────────────────\n// 状態\n// ─────────────────────────────\nvar idsRaw = dflt(Mk:load(\"nicoliveIds\"), \"\")\nvar result = null // null = 未取得 / { live: [], others: [], fetchedAt }\nvar picked = \"\" // ノートにする放送の lv\nvar firstRun = true\n\n/// textarea の生文字列 → 数字だけ・重複なし・最大 MAX_IDS 件\n@parseIds(raw) {\n var ids = []\n each (let line, raw.split(Str:lf)) {\n let s = trim(line)\n if (isAllDigits(s) && !ids.incl(s) && ids.len < MAX_IDS) { ids.push(s) }\n }\n ids\n}\n\n// ─────────────────────────────\n// データ取得\n// ─────────────────────────────\n/// 配信者 1 人の最新番組 → { status: onair | ended | other | none | error, ... }\n@fetchHistory(id) {\n let url = `{HISTORY_API}?providerId={id}&providerType=user&isIncludeNonPublic=false&offset=0&limit=1`\n let res = Nd:http(url, {\n method: \"GET\"\n headers: { \"Accept\": \"application/json\", \"User-Agent\": UA }\n })\n let fallbackName = `ID {id}`\n if (res.status != 200) { return { status: \"error\", id: id, name: fallbackName, err: `HTTP {res.status}` } }\n let data = Json:parse(res.body)\n let list = get(get(data, \"data\", {}), \"programsList\", [])\n if (Core:type(list) != \"arr\" || list.len == 0) { return { status: \"none\", id: id, name: fallbackName } }\n let p = list[0]\n let prog = get(p, \"program\", {})\n let sched = get(prog, \"schedule\", {})\n let stats = get(p, \"statistics\", {})\n let st = get(sched, \"status\", \"\")\n {\n status: if (st == \"ON_AIR\") { \"onair\" } elif (st == \"ENDED\") { \"ended\" } else { \"other\" }\n id: id\n lv: get(get(p, \"id\", {}), \"value\", \"\")\n name: sanitizeText(get(get(p, \"programProvider\", {}), \"name\", fallbackName))\n title: sanitizeText(get(prog, \"title\", \"\"))\n beginMs: num(get(get(sched, \"beginTime\", {}), \"seconds\", 0)) * 1000\n endMs: num(get(get(sched, \"endTime\", {}), \"seconds\", 0)) * 1000\n viewers: num(get(get(stats, \"viewers\", {}), \"value\", 0))\n comments: num(get(get(stats, \"comments\", {}), \"value\", 0))\n }\n}\n\n@fetchData() {\n var live = []\n var others = []\n each (let id, parseIds(idsRaw)) {\n let r = fetchHistory(id)\n if (r.status == \"onair\") { live.push(r) } else { others.push(r) }\n }\n result = { live: live, others: others, fetchedAt: Date:now() }\n if (live.len > 0) {\n var ok = false\n each (let l, live) { if (l.lv == picked) { ok = true } }\n if (!ok) { picked = live[0].lv }\n }\n render()\n}\n\n// ─────────────────────────────\n// UI\n// ─────────────────────────────\n@configFolder(opened) {\n Ui:C:folder({\n title: \"⚙️ 設定\"\n opened: opened\n children: [\n Ui:C:text({ text: `ユーザー ID (1 行に 1 つ、最大 {MAX_IDS} 件)` })\n Ui:C:textarea({\n default: idsRaw\n onInput: @(v) { idsRaw = v; Mk:save(\"nicoliveIds\", v) }\n })\n Ui:C:text({ text: \"プロフィール URL nicovideo.jp/user/<数字> の数字\" })\n Ui:C:button({ text: \"適用して再読み込み\", onClick: @() { fetchData() } })\n ]\n })\n}\n\n/// 赤い LIVE チップ。🔴 は jelly で軽く脈打たせる\n@liveChip() {\n `$[border.radius=4 $[bg.color={LIVE} $[fg.color=ffffff ** $[jelly.speed=2s 🔴] LIVE **]]]`\n}\n\n@liveStats(l) {\n `⏱ 開始 {ago(l.beginMs)} · 👥 来場 {fmtInt(l.viewers)} · 💬 コメ {fmtInt(l.comments)}`\n}\n\n/// 放送中の主役 (1 人目): 見出し → x2 の配信者名 → 番組名 → 小さな統計\n@liveHero(l) {\n [\n Ui:C:mfm({ text: `{liveChip()}` })\n Ui:C:mfm({ text: `$[x2 $[fg.color={ACCENT} **{truncName(l.name, 16)}**]]` })\n Ui:C:mfm({ text: `[{truncName(l.title, 40)}]({watchUrl(l.lv)})` })\n Ui:C:mfm({ text: `$[fg.color={MUTED} {liveStats(l)}]` })\n ]\n}\n\n/// 2 人目以降の放送中 (一覧行)\n@liveRow(l) {\n let l1 = `{liveChip()} **{truncName(l.name, 20)}**`\n let l2 = `[{truncName(l.title, 40)}]({watchUrl(l.lv)})`\n let l3 = `$[fg.color={MUTED} {liveStats(l)}]`\n Ui:C:mfm({ text: `{l1}{Str:lf}{l2}{Str:lf}{l3}` })\n}\n\n@otherRow(r) {\n if (r.status == \"ended\") {\n if (Date:now() - r.endMs < RECENT_END_MS) {\n Ui:C:mfm({ text: `⏹ **{truncName(r.name, 20)}** $[fg.color={MUTED} · 終了 {ago(r.endMs)} ·] [📼 タイムシフト]({watchUrl(r.lv)})` })\n } else {\n Ui:C:mfm({ text: `$[fg.color={MUTED} ○ **{truncName(r.name, 20)}** · 最後の放送 {ago(r.endMs)}]` })\n }\n } elif (r.status == \"other\") {\n Ui:C:mfm({ text: `🕒 **{truncName(r.name, 20)}** $[fg.color={MUTED} · 予定:] [{truncName(r.title, 30)}]({watchUrl(r.lv)})` })\n } elif (r.status == \"none\") {\n Ui:C:mfm({ text: `$[fg.color={MUTED} ◌ ID {r.id} · 放送履歴なし]` })\n } else {\n Ui:C:mfm({ text: `$[fg.color={RED} ⚠️ ID {r.id} · 取得失敗 ({r.err})]` })\n }\n}\n\n@noteText(l) {\n let lf = Str:lf\n `👀 {l.name} の放送を見てる{lf}{l.title}{lf}https://nico.ms/{l.lv}{lf}#ニコ生`\n}\n\n@statusView(res) {\n var body = [\n Ui:C:mfm({ text: `$[fg.color={MUTED} 📡 ニコ生 · 放送中 {res.live.len} / {res.live.len + res.others.len}]` })\n ]\n if (res.live.len == 0) {\n body.push(Ui:C:mfm({ text: `$[x2 😴]` }))\n body.push(Ui:C:mfm({ text: `$[fg.color={MUTED} 放送中の推しはいません]` }))\n } else {\n body = body.concat(liveHero(res.live[0]))\n var pickedRow = res.live[0]\n if (res.live.len > 1) {\n var rows = []\n var items = []\n each (let l, res.live) {\n items.push({ text: `{truncName(l.name, 12)} · {truncName(l.title, 18)}`, value: l.lv })\n if (l.lv == picked) { pickedRow = l }\n }\n for (let i, res.live.len) {\n if (i > 0) { rows.push(liveRow(res.live[i])) }\n }\n // 全体は中央寄せだが、一覧は左寄せの方が読みやすい\n body.push(Ui:C:container({ align: \"left\", children: rows }))\n body.push(Ui:C:select({\n items: items\n default: pickedRow.lv\n label: \"ノートにする放送\"\n onChange: @(v) { picked = v; render() }\n }))\n }\n body.push(Ui:C:container({\n align: \"center\"\n children: [\n Ui:C:postFormButton({ text: \"👀 見てるとノートする\", primary: true, rounded: true, form: { text: noteText(pickedRow) } })\n ]\n }))\n }\n if (res.others.len > 0) {\n body.push(divider())\n var others = []\n each (let r, res.others) { others.push(otherRow(r)) }\n body.push(Ui:C:container({ align: \"left\", children: others }))\n }\n body\n}\n\n@render() {\n let ids = parseIds(idsRaw)\n if (ids.len == 0) {\n Ui:render([\n Ui:C:container({\n align: \"center\"\n padding: 10\n children: [\n Ui:C:mfm({ text: `$[fg.color={MUTED} 📡 推し配信者のユーザー ID を設定してください]` })\n configFolder(true)\n ]\n })\n ])\n return null\n }\n if (firstRun && Core:type(result) == \"null\") {\n Ui:render([\n Ui:C:container({ align: \"center\", padding: 10, children: [ Ui:C:text({ text: \"ニコ生の放送状況を確認中… 📡\" }) ] })\n ])\n firstRun = false\n fetchData()\n return null\n }\n var children = []\n if (Core:type(result) == \"null\") {\n children = [ Ui:C:container({ align: \"center\", children: [\n Ui:C:button({ text: \"🔄 更新\", primary: true, rounded: true, onClick: @() { fetchData() } })\n ] }) ]\n } else {\n children = statusView(result)\n }\n children.push(divider())\n children.push(configFolder(false))\n children.push(Ui:C:container({ align: \"center\", children: [\n Ui:C:button({ text: \"🔄 更新\", rounded: true, onClick: @() { fetchData() } })\n ] }))\n children.push(Ui:C:mfm({ text: `$[fg.color={MUTED} 3 分ごとに確認 · 非公式 API]` }))\n Ui:render([ Ui:C:container({ align: \"center\", padding: 10, children: children }) ])\n}\n\nrender()\nAsync:interval(REFRESH_MS, fetchData, false)\n"}
+{"type":"widget","data":"/// @ 1.2.1\n/// ニコ生 推し配信チェック ウィジェット\n/// 推しのユーザー生放送が「今やっているか」を 3 分ごとに確認し、放送中なら 🔴 タイトル・\n/// 開始からの経過・来場者数を出して、ワンタップで「見てる」ノートを投稿できる。\n///\n/// 設定でユーザー ID を列挙する (最大 5 件、認証不要・ログイン不要)。配信者ごとの放送履歴 API\n/// (live.nicovideo.jp/front/api/v2/user-broadcast-history) を Nd:http で叩き、最新番組の状態を見る。\n/// 終了 1 時間以内は「終了 · タイムシフト」、それ以外は「最後の放送 N 日前」。\n/// ID はプロフィール URL nicovideo.jp/user/<数字> の数字。\n///\n/// 非公式 API なので壊れたら該当行が「取得失敗」になるだけで他の行は巻き込まない。\n/// (ニコニコアカウントのフォロー中を取る API はログイン cookie が要るので扱わない。)\n/// 設計の元ネタ: discord-online (presence の定期取得) / listenbrainz (投稿ボタン)。\n\nlet HISTORY_API = \"https://live.nicovideo.jp/front/api/v2/user-broadcast-history\"\nlet UA = \"misstore-nicolive-watch/1.0 (https://github.com/notedeck-dev/misstore)\"\nlet MAX_IDS = 5\nlet REFRESH_MS = 180000\nlet RECENT_END_MS = 3600000 // 終了からこの時間内は「タイムシフト」案内\n\n// カラーパレット\nlet MUTED = \"8a96a3\"\nlet LINE = \"5a6573\"\nlet LIVE = \"ff4d4d\"\nlet RED = \"ff6961\"\nlet ACCENT = \"ffb300\"\n\n// ─────────────────────────────\n// ヘルパー\n// ─────────────────────────────\n@dflt(v, d) { if (Core:type(v) == \"null\") d else v }\n\n@get(o, k, d) {\n if (Core:type(o) != \"obj\") { d }\n elif (Obj:has(o, k)) { dflt(o[k], d) }\n else { d }\n}\n\n@num(v) {\n if (Core:type(v) == \"num\") { return v }\n if (Core:type(v) != \"str\") { return 0 }\n let n = v.to_num()\n if (Core:type(n) == \"num\") { n } else { 0 }\n}\n\n@trim(s) {\n if (Core:type(s) != \"str\") { return \"\" }\n var a = 0\n var b = s.len\n loop { if (a < b && s.slice(a, a + 1) == \" \") { a = a + 1 } else { break } }\n loop { if (b > a && s.slice(b - 1, b) == \" \") { b = b - 1 } else { break } }\n s.slice(a, b)\n}\n\n@isAllDigits(s) {\n if (s.len == 0) { return false }\n var ok = true\n for (let i, s.len) {\n let c = s.slice(i, i + 1)\n if (Core:type(c.to_num()) != \"num\") { ok = false }\n }\n ok\n}\n\n/// MFM 制御文字を無害化。配信者名・番組名は外部由来なので必ず通す\n@sanitizeText(s) {\n if (Core:type(s) != \"str\") { return \"(不明)\" }\n var txt = s.replace(\"$[\", \"$ \")\n txt = txt.replace(\"]\", \"]\")\n txt = txt.replace(\"[\", \"[\")\n txt = txt.replace(\"(\", \"(\")\n txt = txt.replace(\")\", \")\")\n txt = txt.replace(\"<\", \"<\")\n txt = txt.replace(\">\", \">\")\n txt = txt.replace(\"`\", \"`\")\n txt = txt.replace(\"*\", \"*\")\n txt = txt.replace(\"~\", \"〜\")\n txt = txt.replace(\"\\\\\", \"\\")\n txt = txt.replace(\"@\", \"@\")\n txt = txt.replace(\"#\", \"#\")\n txt = txt.replace(\"://\", \"://\")\n txt\n}\n\n@truncName(s, n) {\n if (s.len > n) { `{s.slice(0, n)}…` } else { s }\n}\n\n/// 桁区切り。1234567 → \"1,234,567\"\n@fmtInt(n) {\n let s = Core:to_str(Math:round(num(n)))\n var out1 = \"\"\n var cnt = 0\n var i = s.len\n loop {\n if (i <= 0) { break }\n out1 = `{s.slice(i - 1, i)}{out1}`\n cnt = cnt + 1\n i = i - 1\n if (cnt % 3 == 0 && i > 0) { out1 = `,{out1}` }\n }\n out1\n}\n\n@ago(ms) {\n let diff = Date:now() - ms\n if (diff < 3600000) { `{Math:max(1, Math:floor(diff / 60000))} 分前` }\n elif (diff < 86400000) { `{Math:floor(diff / 3600000)} 時間前` }\n elif (diff < 2592000000) { `{Math:floor(diff / 86400000)} 日前` }\n elif (diff < 31536000000) { `{Math:floor(diff / 2592000000)} か月前` }\n else { `{Math:floor(diff / 31536000000)} 年前` }\n}\n\n@divider() {\n Ui:C:mfm({ text: `$[fg.color={LINE} ───────────────]` })\n}\n\n@watchUrl(lv) { `https://live.nicovideo.jp/watch/{lv}` }\n\n// ─────────────────────────────\n// 状態\n// ─────────────────────────────\nvar idsRaw = dflt(Mk:load(\"nicoliveIds\"), \"\")\nvar result = null // null = 未取得 / { live: [], others: [], fetchedAt }\nvar picked = \"\" // ノートにする放送の lv\nvar firstRun = true\n\n/// textarea の生文字列 → 数字だけ・重複なし・最大 MAX_IDS 件\n@parseIds(raw) {\n var ids = []\n each (let line, raw.split(Str:lf)) {\n let s = trim(line)\n if (isAllDigits(s) && !ids.incl(s) && ids.len < MAX_IDS) { ids.push(s) }\n }\n ids\n}\n\n// ─────────────────────────────\n// データ取得\n// ─────────────────────────────\n/// 配信者 1 人の最新番組 → { status: onair | ended | other | none | error, ... }\n@fetchHistory(id) {\n let url = `{HISTORY_API}?providerId={id}&providerType=user&isIncludeNonPublic=false&offset=0&limit=1`\n let res = Nd:http(url, {\n method: \"GET\"\n headers: { \"Accept\": \"application/json\", \"User-Agent\": UA }\n })\n let fallbackName = `ID {id}`\n // status 0 は通信断 (NoteDeck は throw せず値で返す、notedeck#1294)\n if (res.status != 200) { return { status: \"error\", id: id, name: fallbackName, err: if (res.status == 0) { \"通信できません\" } else { `HTTP {res.status}` } } }\n let data = Json:parse(res.body)\n let list = get(get(data, \"data\", {}), \"programsList\", [])\n if (Core:type(list) != \"arr\" || list.len == 0) { return { status: \"none\", id: id, name: fallbackName } }\n let p = list[0]\n let prog = get(p, \"program\", {})\n let sched = get(prog, \"schedule\", {})\n let stats = get(p, \"statistics\", {})\n let st = get(sched, \"status\", \"\")\n {\n status: if (st == \"ON_AIR\") { \"onair\" } elif (st == \"ENDED\") { \"ended\" } else { \"other\" }\n id: id\n lv: get(get(p, \"id\", {}), \"value\", \"\")\n name: sanitizeText(get(get(p, \"programProvider\", {}), \"name\", fallbackName))\n title: sanitizeText(get(prog, \"title\", \"\"))\n beginMs: num(get(get(sched, \"beginTime\", {}), \"seconds\", 0)) * 1000\n endMs: num(get(get(sched, \"endTime\", {}), \"seconds\", 0)) * 1000\n viewers: num(get(get(stats, \"viewers\", {}), \"value\", 0))\n comments: num(get(get(stats, \"comments\", {}), \"value\", 0))\n }\n}\n\n@fetchData() {\n var live = []\n var others = []\n each (let id, parseIds(idsRaw)) {\n let r = fetchHistory(id)\n if (r.status == \"onair\") { live.push(r) } else { others.push(r) }\n }\n result = { live: live, others: others, fetchedAt: Date:now() }\n if (live.len > 0) {\n var ok = false\n each (let l, live) { if (l.lv == picked) { ok = true } }\n if (!ok) { picked = live[0].lv }\n }\n render()\n}\n\n// ─────────────────────────────\n// UI\n// ─────────────────────────────\n@configFolder(opened) {\n Ui:C:folder({\n title: \"⚙️ 設定\"\n opened: opened\n children: [\n Ui:C:text({ text: `ユーザー ID (1 行に 1 つ、最大 {MAX_IDS} 件)` })\n Ui:C:textarea({\n default: idsRaw\n onInput: @(v) { idsRaw = v; Mk:save(\"nicoliveIds\", v) }\n })\n Ui:C:text({ text: \"プロフィール URL nicovideo.jp/user/<数字> の数字\" })\n Ui:C:button({ text: \"適用して再読み込み\", onClick: @() { fetchData() } })\n ]\n })\n}\n\n/// 赤い LIVE チップ。🔴 は jelly で軽く脈打たせる\n@liveChip() {\n `$[border.radius=4 $[bg.color={LIVE} $[fg.color=ffffff ** $[jelly.speed=2s 🔴] LIVE **]]]`\n}\n\n@liveStats(l) {\n `⏱ 開始 {ago(l.beginMs)} · 👥 来場 {fmtInt(l.viewers)} · 💬 コメ {fmtInt(l.comments)}`\n}\n\n/// 放送中の主役 (1 人目): 見出し → x2 の配信者名 → 番組名 → 小さな統計\n@liveHero(l) {\n [\n Ui:C:mfm({ text: `{liveChip()}` })\n Ui:C:mfm({ text: `$[x2 $[fg.color={ACCENT} **{truncName(l.name, 16)}**]]` })\n Ui:C:mfm({ text: `[{truncName(l.title, 40)}]({watchUrl(l.lv)})` })\n Ui:C:mfm({ text: `$[fg.color={MUTED} {liveStats(l)}]` })\n ]\n}\n\n/// 2 人目以降の放送中 (一覧行)\n@liveRow(l) {\n let l1 = `{liveChip()} **{truncName(l.name, 20)}**`\n let l2 = `[{truncName(l.title, 40)}]({watchUrl(l.lv)})`\n let l3 = `$[fg.color={MUTED} {liveStats(l)}]`\n Ui:C:mfm({ text: `{l1}{Str:lf}{l2}{Str:lf}{l3}` })\n}\n\n@otherRow(r) {\n if (r.status == \"ended\") {\n if (Date:now() - r.endMs < RECENT_END_MS) {\n Ui:C:mfm({ text: `⏹ **{truncName(r.name, 20)}** $[fg.color={MUTED} · 終了 {ago(r.endMs)} ·] [📼 タイムシフト]({watchUrl(r.lv)})` })\n } else {\n Ui:C:mfm({ text: `$[fg.color={MUTED} ○ **{truncName(r.name, 20)}** · 最後の放送 {ago(r.endMs)}]` })\n }\n } elif (r.status == \"other\") {\n Ui:C:mfm({ text: `🕒 **{truncName(r.name, 20)}** $[fg.color={MUTED} · 予定:] [{truncName(r.title, 30)}]({watchUrl(r.lv)})` })\n } elif (r.status == \"none\") {\n Ui:C:mfm({ text: `$[fg.color={MUTED} ◌ ID {r.id} · 放送履歴なし]` })\n } else {\n Ui:C:mfm({ text: `$[fg.color={RED} ⚠️ ID {r.id} · 取得失敗 ({r.err})]` })\n }\n}\n\n@noteText(l) {\n let lf = Str:lf\n `👀 {l.name} の放送を見てる{lf}{l.title}{lf}https://nico.ms/{l.lv}{lf}#ニコ生`\n}\n\n@statusView(res) {\n var body = [\n Ui:C:mfm({ text: `$[fg.color={MUTED} 📡 ニコ生 · 放送中 {res.live.len} / {res.live.len + res.others.len}]` })\n ]\n if (res.live.len == 0) {\n body.push(Ui:C:mfm({ text: `$[x2 😴]` }))\n body.push(Ui:C:mfm({ text: `$[fg.color={MUTED} 放送中の推しはいません]` }))\n } else {\n body = body.concat(liveHero(res.live[0]))\n var pickedRow = res.live[0]\n if (res.live.len > 1) {\n var rows = []\n var items = []\n each (let l, res.live) {\n items.push({ text: `{truncName(l.name, 12)} · {truncName(l.title, 18)}`, value: l.lv })\n if (l.lv == picked) { pickedRow = l }\n }\n for (let i, res.live.len) {\n if (i > 0) { rows.push(liveRow(res.live[i])) }\n }\n // 全体は中央寄せだが、一覧は左寄せの方が読みやすい\n body.push(Ui:C:container({ align: \"left\", children: rows }))\n body.push(Ui:C:select({\n items: items\n default: pickedRow.lv\n label: \"ノートにする放送\"\n onChange: @(v) { picked = v; render() }\n }))\n }\n body.push(Ui:C:container({\n align: \"center\"\n children: [\n Ui:C:postFormButton({ text: \"👀 見てるとノートする\", primary: true, rounded: true, form: { text: noteText(pickedRow) } })\n ]\n }))\n }\n if (res.others.len > 0) {\n body.push(divider())\n var others = []\n each (let r, res.others) { others.push(otherRow(r)) }\n body.push(Ui:C:container({ align: \"left\", children: others }))\n }\n body\n}\n\n@render() {\n let ids = parseIds(idsRaw)\n if (ids.len == 0) {\n Ui:render([\n Ui:C:container({\n align: \"center\"\n padding: 10\n children: [\n Ui:C:mfm({ text: `$[fg.color={MUTED} 📡 推し配信者のユーザー ID を設定してください]` })\n configFolder(true)\n ]\n })\n ])\n return null\n }\n if (firstRun && Core:type(result) == \"null\") {\n Ui:render([\n Ui:C:container({ align: \"center\", padding: 10, children: [ Ui:C:text({ text: \"ニコ生の放送状況を確認中… 📡\" }) ] })\n ])\n firstRun = false\n fetchData()\n return null\n }\n var children = []\n if (Core:type(result) == \"null\") {\n children = [ Ui:C:container({ align: \"center\", children: [\n Ui:C:button({ text: \"🔄 更新\", primary: true, rounded: true, onClick: @() { fetchData() } })\n ] }) ]\n } else {\n children = statusView(result)\n }\n children.push(divider())\n children.push(configFolder(false))\n children.push(Ui:C:container({ align: \"center\", children: [\n Ui:C:button({ text: \"🔄 更新\", rounded: true, onClick: @() { fetchData() } })\n ] }))\n children.push(Ui:C:mfm({ text: `$[fg.color={MUTED} 3 分ごとに確認 · 非公式 API]` }))\n Ui:render([ Ui:C:container({ align: \"center\", padding: 10, children: children }) ])\n}\n\nrender()\nAsync:interval(REFRESH_MS, fetchData, false)\n"}
diff --git a/public/registry/widgets/nicolive-watch/widget.is b/public/registry/widgets/nicolive-watch/widget.is
index ec1f5a2..70265a7 100644
--- a/public/registry/widgets/nicolive-watch/widget.is
+++ b/public/registry/widgets/nicolive-watch/widget.is
@@ -146,7 +146,8 @@ var firstRun = true
headers: { "Accept": "application/json", "User-Agent": UA }
})
let fallbackName = `ID {id}`
- if (res.status != 200) { return { status: "error", id: id, name: fallbackName, err: `HTTP {res.status}` } }
+ // status 0 は通信断 (NoteDeck は throw せず値で返す、notedeck#1294)
+ if (res.status != 200) { return { status: "error", id: id, name: fallbackName, err: if (res.status == 0) { "通信できません" } else { `HTTP {res.status}` } } }
let data = Json:parse(res.body)
let list = get(get(data, "data", {}), "programsList", [])
if (Core:type(list) != "arr" || list.len == 0) { return { status: "none", id: id, name: fallbackName } }
diff --git a/public/registry/widgets/nodeinfo/api.json b/public/registry/widgets/nodeinfo/api.json
index ff52344..1e04622 100644
--- a/public/registry/widgets/nodeinfo/api.json
+++ b/public/registry/widgets/nodeinfo/api.json
@@ -1 +1 @@
-{"type":"widget","data":"/// @ 1.2.1\n/// nodeinfo ウィジェット\n/// 任意の Fediverse サーバーの nodeinfo を引いて、ソフトウェア・規模・登録受付状態を表示。\n/// 認証不要・レスポンス数 KB で、Misskey に限らず Mastodon / Pleroma / Akkoma などにも通る。\n///\n/// 実装メモ:\n/// - nodeinfo は 2 段構え。/.well-known/nodeinfo で本体 URL を discovery してから取得する。\n/// 本体パスは実装ごとに違う (Pleroma は /nodeinfo/2.1.json) ので、discovery は省略できない\n/// - 2.1 を優先し、無ければ 2.0。Mastodon は 2.0 のみを出す\n/// - discovery が返す href は**相手サーバーが自由に決められる値**なので、\n/// 「調査対象ホスト自身の https URL」以外は捨てる (別ドメインへ取りに行かせないため)\n/// - nodeName も相手の自由入力。MFM / リンク / HTML 記法は safeLabel で全角に潰す。\n/// nodeDescription は HTML 混じりで無害化のコストが高いので最初から表示しない\n/// - 内容はほぼ静的なので自動更新は 6 時間おき\n\nlet WELL_KNOWN_REL_20 = \"http://nodeinfo.diaspora.software/ns/schema/2.0\"\nlet WELL_KNOWN_REL_21 = \"http://nodeinfo.diaspora.software/ns/schema/2.1\"\nlet HOST_KEY = \"nodeinfo:host\"\nlet DEFAULT_HOST = \"misskey.io\"\nlet HOST_CHARS = \"abcdefghijklmnopqrstuvwxyz0123456789.-\"\n// nodeinfo は大きくても数 KB。相手が返す量を無制限にパースしない\nlet MAX_BODY = 200000\n\n// カラーパレット (ink-new-servers と同系統)\nlet COL_OPEN = \"86b300\" // 登録開放\nlet COL_DOWN = \"f74e52\" // エラー\nlet COL_SUB = \"6b7480\" // 補助・招待制\n\n// ─────────────────────────────\n// ヘルパー\n// ─────────────────────────────\n@dflt(v, d) { if (Core:type(v) == \"null\") d else v }\n\n@get(o, k, d) {\n if (Core:type(o) != \"obj\") { d }\n elif (Obj:has(o, k)) { dflt(o[k], d) }\n else { d }\n}\n\n/// レスポンス本文を JSON オブジェクトとして読む。\n/// Json:parse は失敗時に throw せず error 値を返すので、obj 以外はすべて null に潰す\n@parseObj(body) {\n if (Core:type(body) != \"str\") { return null }\n if (body.len > MAX_BODY) { return null }\n let v = Json:parse(body)\n if (Core:type(v) == \"obj\") v else null\n}\n\n/// 数値を日本語の桁表記に (731141 -> 73.1万, 172951329 -> 1.7億)\n@fmtNum(n) {\n if (Core:type(n) != \"num\") { \"?\" }\n elif (n >= 100000000) { `{Math:round(n / 10000000) / 10}億` }\n elif (n >= 10000) { `{Math:round(n / 1000) / 10}万` }\n else { `{Math:round(n)}` }\n}\n\n/// 相手サーバー由来の文字列を表示用に無害化する。\n/// MFM ($[...])・リンク ([...])・装飾 (*)・HTML (<>) を全角に置換して発火させない\n@safeLabel(s) {\n if (Core:type(s) != \"str\") { \"?\" }\n else {\n s.replace(\"$\", \"$\").replace(\"[\", \"[\").replace(\"]\", \"]\").replace(\"*\", \"*\").replace(\"<\", \"<\").replace(\">\", \">\")\n }\n}\n\n@trunc(s, n) {\n if (s.len > n) `{s.slice(0, n)}…` else s\n}\n\n/// ホスト名として妥当か。ドットを含む小文字英数 + ハイフンのみ許す\n/// (ポート付き・IDN・localhost は弾く)\n@validHost(h) {\n if (!h.incl(\".\")) { return false }\n if (h.starts_with(\".\") || h.starts_with(\"-\")) { return false }\n if (h.ends_with(\".\") || h.ends_with(\"-\")) { return false }\n var ok = true\n each (let c, h.to_char_arr()) {\n if (!HOST_CHARS.incl(c)) { ok = false }\n }\n ok\n}\n\n/// 入力欄の値からホスト名を取り出す。URL 貼り付け・@user@host・大文字を許容。\n/// 妥当でなければ null\n@normalizeHost(s) {\n if (Core:type(s) != \"str\") { return null }\n var h = s.trim().lower()\n if (h.starts_with(\"https://\")) { h = h.slice(8, h.len) }\n elif (h.starts_with(\"http://\")) { h = h.slice(7, h.len) }\n // @user@host 形式は最後の @ より後ろを取る\n let parts = h.split(\"@\")\n h = parts[parts.len - 1]\n let slash = h.index_of(\"/\")\n if (slash >= 0) { h = h.slice(0, slash) }\n if (h == \"\") { return null }\n if (!validHost(h)) { return null }\n h\n}\n\n/// href が調査対象ホスト自身の https URL か\n@ownHttpsUrl(href, host) {\n if (Core:type(href) != \"str\") { return false }\n href.lower().starts_with(`https://{host}/`)\n}\n\n/// discovery の links から本体 URL を選ぶ (2.1 優先、自ホストの https のみ)\n@pickNodeinfoUrl(links, host) {\n if (Core:type(links) != \"arr\") { return null }\n var url20 = null\n var url21 = null\n each (let l, links) {\n let href = get(l, \"href\", null)\n if (ownHttpsUrl(href, host)) {\n let rel = get(l, \"rel\", \"\")\n if (rel == WELL_KNOWN_REL_21) { url21 = href }\n elif (rel == WELL_KNOWN_REL_20) { url20 = href }\n }\n }\n if (Core:type(url21) == \"str\") url21 else url20\n}\n\n/// ソフトウェア名ごとの色 (Misskey 系は緑、Mastodon は紫、Pleroma 系は橙)\n@softwareColor(name) {\n if (name == \"misskey\" || name == \"sharkey\" || name == \"cherrypick\") { COL_OPEN }\n elif (name == \"firefish\" || name == \"iceshrimp\") { COL_OPEN }\n elif (name == \"mastodon\" || name == \"hometown\") { \"6364ff\" }\n elif (name == \"pleroma\" || name == \"akkoma\") { \"fba457\" }\n else { COL_SUB }\n}\n\n@softwareName(ni) {\n let name = get(get(ni, \"software\", null), \"name\", null)\n if (Core:type(name) != \"str\") \"\" else name.lower()\n}\n\n/// \"misskey v2025.4.1\" 形式のバッジ本文\n@softwareLabel(ni) {\n let sw = get(ni, \"software\", null)\n let name = get(sw, \"name\", null)\n if (Core:type(name) != \"str\") { return \"不明\" }\n let ver = get(sw, \"version\", null)\n if (Core:type(ver) != \"str\") { safeLabel(trunc(name, 24)) }\n else { `{safeLabel(trunc(name, 24))} v{safeLabel(trunc(ver, 32))}` }\n}\n\n/// 登録受付状態 (色付き)。emailRequiredForSignup は Misskey 系のみ持つ\n@regLabel(ni) {\n if (get(ni, \"openRegistrations\", false) != true) {\n `$[fg.color={COL_SUB} 招待制]`\n } elif (get(get(ni, \"metadata\", null), \"emailRequiredForSignup\", false) == true) {\n `$[fg.color={COL_OPEN} 登録開放] $[fg.color={COL_SUB} ✉要メール]`\n } else {\n `$[fg.color={COL_OPEN} 登録開放]`\n }\n}\n\n/// 表示行 (MFM 文字列) を組み立てる。常に 5 行\n@infoRows(ni, host) {\n let name = trunc(safeLabel(dflt(get(get(ni, \"metadata\", null), \"nodeName\", null), host)), 28)\n let users = get(get(get(ni, \"usage\", null), \"users\", null), \"total\", null)\n let posts = get(get(ni, \"usage\", null), \"localPosts\", null)\n let active = get(get(get(ni, \"usage\", null), \"users\", null), \"activeMonth\", null)\n var stats = `👥 **{fmtNum(users)}** 📝 **{fmtNum(posts)}**`\n if (Core:type(active) == \"num\") {\n stats = `{stats} 🔥 **{fmtNum(active)}**`\n }\n let protocols = get(ni, \"protocols\", null)\n var footer = `nodeinfo {dflt(get(ni, \"version\", null), \"?\")}`\n if (Core:type(protocols) == \"arr\" && protocols.len > 0) {\n footer = `{footer} ・ {safeLabel(trunc(protocols.join(\", \"), 40))}`\n }\n [\n `🌐 [**{name}**](https://{host})`\n `$[bg.color={softwareColor(softwareName(ni))} $[fg.color=ffffff {softwareLabel(ni)} ]]`\n `{stats}`\n `{regLabel(ni)}`\n `$[fg.color={COL_SUB} {footer}]`\n ]\n}\n\n// ---- ここから UI 層 (ハーネス検証はこの行より上の純粋関数のみ) ----\n\n// 保存値も信用せず normalizeHost に通す (壊れた値が URL 組み立てに乗らないように)\nvar host = dflt(normalizeHost(Mk:load(HOST_KEY)), DEFAULT_HOST)\nvar input = host\nvar firstRun = true\nvar errorMsg = null\n\n/// nodeinfo を取得する。失敗時は null を返し errorMsg に理由を入れる\n@fetchNodeinfo(h) {\n errorMsg = null\n let disc = Nd:http(`https://{h}/.well-known/nodeinfo`, {\n method: \"GET\"\n headers: { \"Accept\": \"application/json\" }\n })\n if (disc.status != 200) {\n errorMsg = `nodeinfo が見つかりません (HTTP {disc.status})`\n return null\n }\n let url = pickNodeinfoUrl(get(parseObj(disc.body), \"links\", null), h)\n if (Core:type(url) != \"str\") {\n errorMsg = \"対応する nodeinfo (2.0 / 2.1) のリンクがありません\"\n return null\n }\n let res = Nd:http(url, {\n method: \"GET\"\n headers: { \"Accept\": \"application/json\" }\n })\n if (res.status != 200) {\n errorMsg = `nodeinfo を取得できません (HTTP {res.status})`\n return null\n }\n let ni = parseObj(res.body)\n if (Core:type(ni) != \"obj\") {\n errorMsg = \"nodeinfo の応答を読めませんでした\"\n }\n ni\n}\n\n@configFolder() {\n Ui:C:folder({\n title: \"⚙️ 調べるサーバー\"\n opened: false\n children: [\n Ui:C:textInput({\n default: input\n label: \"ホスト\"\n text: \"misskey.io / https://mastodon.social など\"\n onInput: @(v) { input = v }\n })\n Ui:C:button({\n text: \"🔍 調べる\"\n rounded: true\n primary: true\n onClick: @() {\n let h = normalizeHost(input)\n if (Core:type(h) != \"str\") {\n Mk:dialog(\"エラー\", \"ホスト名が不正です。misskey.io のような形式で入力してください\", \"error\")\n } else {\n // 保存は取得成功後 (render 内)。名前が引けないホストで Nd:http が\n // 落ちた場合に、その値が次回起動時にも読み込まれて詰むのを避ける\n host = h\n input = h\n firstRun = true\n render()\n }\n }\n })\n ]\n })\n}\n\n@render() {\n if (firstRun) {\n Ui:render([\n Ui:C:container({\n align: \"center\"\n children: [Ui:C:text({ text: `{host} を調べています… 🌐` })]\n })\n ])\n }\n firstRun = false\n\n let ni = fetchNodeinfo(host)\n if (Core:type(ni) != \"obj\") {\n Ui:render([\n Ui:C:container({\n align: \"center\"\n children: [\n Ui:C:mfm({ text: `$[fg.color={COL_SUB} {safeLabel(host)}]` })\n Ui:C:mfm({ text: `$[fg.color={COL_DOWN} ⚠️ {dflt(errorMsg, \"取得に失敗しました\")}]` })\n Ui:C:button({ text: \"🔄 再試行\", rounded: true, onClick: @() { render() } })\n configFolder()\n ]\n })\n ])\n return null\n }\n\n Mk:save(HOST_KEY, host)\n\n var body = []\n each (let r, infoRows(ni, host)) {\n body.push(Ui:C:mfm({ text: r }))\n }\n body.push(Ui:C:button({ text: \"🔄 更新\", rounded: true, onClick: @() { render() } }))\n body.push(configFolder())\n Ui:render([\n Ui:C:container({\n align: \"center\"\n children: body\n })\n ])\n}\n\nrender()\n// nodeinfo の内容はほぼ静的なので 6 時間おきで十分\nAsync:interval(21600000, render, false)\n"}
+{"type":"widget","data":"/// @ 1.2.1\n/// nodeinfo ウィジェット\n/// 任意の Fediverse サーバーの nodeinfo を引いて、ソフトウェア・規模・登録受付状態を表示。\n/// 認証不要・レスポンス数 KB で、Misskey に限らず Mastodon / Pleroma / Akkoma などにも通る。\n///\n/// 実装メモ:\n/// - nodeinfo は 2 段構え。/.well-known/nodeinfo で本体 URL を discovery してから取得する。\n/// 本体パスは実装ごとに違う (Pleroma は /nodeinfo/2.1.json) ので、discovery は省略できない\n/// - 2.1 を優先し、無ければ 2.0。Mastodon は 2.0 のみを出す\n/// - discovery が返す href は**相手サーバーが自由に決められる値**なので、\n/// 「調査対象ホスト自身の https URL」以外は捨てる (別ドメインへ取りに行かせないため)\n/// - nodeName も相手の自由入力。MFM / リンク / HTML 記法は safeLabel で全角に潰す。\n/// nodeDescription は HTML 混じりで無害化のコストが高いので最初から表示しない\n/// - 内容はほぼ静的なので自動更新は 6 時間おき\n\nlet WELL_KNOWN_REL_20 = \"http://nodeinfo.diaspora.software/ns/schema/2.0\"\nlet WELL_KNOWN_REL_21 = \"http://nodeinfo.diaspora.software/ns/schema/2.1\"\nlet HOST_KEY = \"nodeinfo:host\"\nlet DEFAULT_HOST = \"misskey.io\"\nlet HOST_CHARS = \"abcdefghijklmnopqrstuvwxyz0123456789.-\"\n// nodeinfo は大きくても数 KB。相手が返す量を無制限にパースしない\nlet MAX_BODY = 200000\n\n// カラーパレット (ink-new-servers と同系統)\nlet COL_OPEN = \"86b300\" // 登録開放\nlet COL_DOWN = \"f74e52\" // エラー\nlet COL_SUB = \"6b7480\" // 補助・招待制\n\n// ─────────────────────────────\n// ヘルパー\n// ─────────────────────────────\n@dflt(v, d) { if (Core:type(v) == \"null\") d else v }\n\n@get(o, k, d) {\n if (Core:type(o) != \"obj\") { d }\n elif (Obj:has(o, k)) { dflt(o[k], d) }\n else { d }\n}\n\n/// レスポンス本文を JSON オブジェクトとして読む。\n/// Json:parse は失敗時に throw せず error 値を返すので、obj 以外はすべて null に潰す\n@parseObj(body) {\n if (Core:type(body) != \"str\") { return null }\n if (body.len > MAX_BODY) { return null }\n let v = Json:parse(body)\n if (Core:type(v) == \"obj\") v else null\n}\n\n/// 数値を日本語の桁表記に (731141 -> 73.1万, 172951329 -> 1.7億)\n@fmtNum(n) {\n if (Core:type(n) != \"num\") { \"?\" }\n elif (n >= 100000000) { `{Math:round(n / 10000000) / 10}億` }\n elif (n >= 10000) { `{Math:round(n / 1000) / 10}万` }\n else { `{Math:round(n)}` }\n}\n\n/// 相手サーバー由来の文字列を表示用に無害化する。\n/// MFM ($[...])・リンク ([...])・装飾 (*)・HTML (<>) を全角に置換して発火させない\n@safeLabel(s) {\n if (Core:type(s) != \"str\") { \"?\" }\n else {\n s.replace(\"$\", \"$\").replace(\"[\", \"[\").replace(\"]\", \"]\").replace(\"*\", \"*\").replace(\"<\", \"<\").replace(\">\", \">\")\n }\n}\n\n@trunc(s, n) {\n if (s.len > n) `{s.slice(0, n)}…` else s\n}\n\n/// ホスト名として妥当か。ドットを含む小文字英数 + ハイフンのみ許す\n/// (ポート付き・IDN・localhost は弾く)\n@validHost(h) {\n if (!h.incl(\".\")) { return false }\n if (h.starts_with(\".\") || h.starts_with(\"-\")) { return false }\n if (h.ends_with(\".\") || h.ends_with(\"-\")) { return false }\n var ok = true\n each (let c, h.to_char_arr()) {\n if (!HOST_CHARS.incl(c)) { ok = false }\n }\n ok\n}\n\n/// 入力欄の値からホスト名を取り出す。URL 貼り付け・@user@host・大文字を許容。\n/// 妥当でなければ null\n@normalizeHost(s) {\n if (Core:type(s) != \"str\") { return null }\n var h = s.trim().lower()\n if (h.starts_with(\"https://\")) { h = h.slice(8, h.len) }\n elif (h.starts_with(\"http://\")) { h = h.slice(7, h.len) }\n // @user@host 形式は最後の @ より後ろを取る\n let parts = h.split(\"@\")\n h = parts[parts.len - 1]\n let slash = h.index_of(\"/\")\n if (slash >= 0) { h = h.slice(0, slash) }\n if (h == \"\") { return null }\n if (!validHost(h)) { return null }\n h\n}\n\n/// href が調査対象ホスト自身の https URL か\n@ownHttpsUrl(href, host) {\n if (Core:type(href) != \"str\") { return false }\n href.lower().starts_with(`https://{host}/`)\n}\n\n/// discovery の links から本体 URL を選ぶ (2.1 優先、自ホストの https のみ)\n@pickNodeinfoUrl(links, host) {\n if (Core:type(links) != \"arr\") { return null }\n var url20 = null\n var url21 = null\n each (let l, links) {\n let href = get(l, \"href\", null)\n if (ownHttpsUrl(href, host)) {\n let rel = get(l, \"rel\", \"\")\n if (rel == WELL_KNOWN_REL_21) { url21 = href }\n elif (rel == WELL_KNOWN_REL_20) { url20 = href }\n }\n }\n if (Core:type(url21) == \"str\") url21 else url20\n}\n\n/// ソフトウェア名ごとの色 (Misskey 系は緑、Mastodon は紫、Pleroma 系は橙)\n@softwareColor(name) {\n if (name == \"misskey\" || name == \"sharkey\" || name == \"cherrypick\") { COL_OPEN }\n elif (name == \"firefish\" || name == \"iceshrimp\") { COL_OPEN }\n elif (name == \"mastodon\" || name == \"hometown\") { \"6364ff\" }\n elif (name == \"pleroma\" || name == \"akkoma\") { \"fba457\" }\n else { COL_SUB }\n}\n\n@softwareName(ni) {\n let name = get(get(ni, \"software\", null), \"name\", null)\n if (Core:type(name) != \"str\") \"\" else name.lower()\n}\n\n/// \"misskey v2025.4.1\" 形式のバッジ本文\n@softwareLabel(ni) {\n let sw = get(ni, \"software\", null)\n let name = get(sw, \"name\", null)\n if (Core:type(name) != \"str\") { return \"不明\" }\n let ver = get(sw, \"version\", null)\n if (Core:type(ver) != \"str\") { safeLabel(trunc(name, 24)) }\n else { `{safeLabel(trunc(name, 24))} v{safeLabel(trunc(ver, 32))}` }\n}\n\n/// 登録受付状態 (色付き)。emailRequiredForSignup は Misskey 系のみ持つ\n@regLabel(ni) {\n if (get(ni, \"openRegistrations\", false) != true) {\n `$[fg.color={COL_SUB} 招待制]`\n } elif (get(get(ni, \"metadata\", null), \"emailRequiredForSignup\", false) == true) {\n `$[fg.color={COL_OPEN} 登録開放] $[fg.color={COL_SUB} ✉要メール]`\n } else {\n `$[fg.color={COL_OPEN} 登録開放]`\n }\n}\n\n/// 表示行 (MFM 文字列) を組み立てる。常に 5 行\n@infoRows(ni, host) {\n let name = trunc(safeLabel(dflt(get(get(ni, \"metadata\", null), \"nodeName\", null), host)), 28)\n let users = get(get(get(ni, \"usage\", null), \"users\", null), \"total\", null)\n let posts = get(get(ni, \"usage\", null), \"localPosts\", null)\n let active = get(get(get(ni, \"usage\", null), \"users\", null), \"activeMonth\", null)\n var stats = `👥 **{fmtNum(users)}** 📝 **{fmtNum(posts)}**`\n if (Core:type(active) == \"num\") {\n stats = `{stats} 🔥 **{fmtNum(active)}**`\n }\n let protocols = get(ni, \"protocols\", null)\n var footer = `nodeinfo {dflt(get(ni, \"version\", null), \"?\")}`\n if (Core:type(protocols) == \"arr\" && protocols.len > 0) {\n footer = `{footer} ・ {safeLabel(trunc(protocols.join(\", \"), 40))}`\n }\n [\n `🌐 [**{name}**](https://{host})`\n `$[bg.color={softwareColor(softwareName(ni))} $[fg.color=ffffff {softwareLabel(ni)} ]]`\n `{stats}`\n `{regLabel(ni)}`\n `$[fg.color={COL_SUB} {footer}]`\n ]\n}\n\n// ---- ここから UI 層 (ハーネス検証はこの行より上の純粋関数のみ) ----\n\n// 保存値も信用せず normalizeHost に通す (壊れた値が URL 組み立てに乗らないように)\nvar host = dflt(normalizeHost(Mk:load(HOST_KEY)), DEFAULT_HOST)\nvar input = host\nvar firstRun = true\nvar errorMsg = null\n\n/// nodeinfo を取得する。失敗時は null を返し errorMsg に理由を入れる\n@fetchNodeinfo(h) {\n errorMsg = null\n let disc = Nd:http(`https://{h}/.well-known/nodeinfo`, {\n method: \"GET\"\n headers: { \"Accept\": \"application/json\" }\n })\n if (disc.status != 200) {\n // status 0 は通信断 (NoteDeck は throw せず値で返す、notedeck#1294)\n errorMsg = if (disc.status == 0) { \"サーバーに接続できません\" } else { `nodeinfo が見つかりません (HTTP {disc.status})` }\n return null\n }\n let url = pickNodeinfoUrl(get(parseObj(disc.body), \"links\", null), h)\n if (Core:type(url) != \"str\") {\n errorMsg = \"対応する nodeinfo (2.0 / 2.1) のリンクがありません\"\n return null\n }\n let res = Nd:http(url, {\n method: \"GET\"\n headers: { \"Accept\": \"application/json\" }\n })\n if (res.status != 200) {\n errorMsg = if (res.status == 0) { \"サーバーに接続できません\" } else { `nodeinfo を取得できません (HTTP {res.status})` }\n return null\n }\n let ni = parseObj(res.body)\n if (Core:type(ni) != \"obj\") {\n errorMsg = \"nodeinfo の応答を読めませんでした\"\n }\n ni\n}\n\n@configFolder() {\n Ui:C:folder({\n title: \"⚙️ 調べるサーバー\"\n opened: false\n children: [\n Ui:C:textInput({\n default: input\n label: \"ホスト\"\n text: \"misskey.io / https://mastodon.social など\"\n onInput: @(v) { input = v }\n })\n Ui:C:button({\n text: \"🔍 調べる\"\n rounded: true\n primary: true\n onClick: @() {\n let h = normalizeHost(input)\n if (Core:type(h) != \"str\") {\n Mk:dialog(\"エラー\", \"ホスト名が不正です。misskey.io のような形式で入力してください\", \"error\")\n } else {\n // 保存は取得成功後 (render 内)。名前が引けないホストで Nd:http が\n // 落ちた場合に、その値が次回起動時にも読み込まれて詰むのを避ける\n host = h\n input = h\n firstRun = true\n render()\n }\n }\n })\n ]\n })\n}\n\n@render() {\n if (firstRun) {\n Ui:render([\n Ui:C:container({\n align: \"center\"\n children: [Ui:C:text({ text: `{host} を調べています… 🌐` })]\n })\n ])\n }\n firstRun = false\n\n let ni = fetchNodeinfo(host)\n if (Core:type(ni) != \"obj\") {\n Ui:render([\n Ui:C:container({\n align: \"center\"\n children: [\n Ui:C:mfm({ text: `$[fg.color={COL_SUB} {safeLabel(host)}]` })\n Ui:C:mfm({ text: `$[fg.color={COL_DOWN} ⚠️ {dflt(errorMsg, \"取得に失敗しました\")}]` })\n Ui:C:button({ text: \"🔄 再試行\", rounded: true, onClick: @() { render() } })\n configFolder()\n ]\n })\n ])\n return null\n }\n\n Mk:save(HOST_KEY, host)\n\n var body = []\n each (let r, infoRows(ni, host)) {\n body.push(Ui:C:mfm({ text: r }))\n }\n body.push(Ui:C:button({ text: \"🔄 更新\", rounded: true, onClick: @() { render() } }))\n body.push(configFolder())\n Ui:render([\n Ui:C:container({\n align: \"center\"\n children: body\n })\n ])\n}\n\nrender()\n// nodeinfo の内容はほぼ静的なので 6 時間おきで十分\nAsync:interval(21600000, render, false)\n"}
diff --git a/public/registry/widgets/nodeinfo/widget.is b/public/registry/widgets/nodeinfo/widget.is
index 1af4379..7cf2edb 100644
--- a/public/registry/widgets/nodeinfo/widget.is
+++ b/public/registry/widgets/nodeinfo/widget.is
@@ -194,7 +194,8 @@ var errorMsg = null
headers: { "Accept": "application/json" }
})
if (disc.status != 200) {
- errorMsg = `nodeinfo が見つかりません (HTTP {disc.status})`
+ // status 0 は通信断 (NoteDeck は throw せず値で返す、notedeck#1294)
+ errorMsg = if (disc.status == 0) { "サーバーに接続できません" } else { `nodeinfo が見つかりません (HTTP {disc.status})` }
return null
}
let url = pickNodeinfoUrl(get(parseObj(disc.body), "links", null), h)
@@ -207,7 +208,7 @@ var errorMsg = null
headers: { "Accept": "application/json" }
})
if (res.status != 200) {
- errorMsg = `nodeinfo を取得できません (HTTP {res.status})`
+ errorMsg = if (res.status == 0) { "サーバーに接続できません" } else { `nodeinfo を取得できません (HTTP {res.status})` }
return null
}
let ni = parseObj(res.body)
diff --git a/public/registry/widgets/okj-prices/api.json b/public/registry/widgets/okj-prices/api.json
index 146d11e..0093195 100644
--- a/public/registry/widgets/okj-prices/api.json
+++ b/public/registry/widgets/okj-prices/api.json
@@ -1 +1 @@
-{"type":"widget","data":"/// @ 1.2.1\n/// OKJ 円建て価格ボードウィジェット (ハイブリッド型)\n/// OKCoinJapan (OKJ) の公開 API で国内取引所のリアルな売買データを表示。API キー不要。\n/// フォーカス銘柄 (既定 ETH) の板 3 段×2 + スプレッド + 歩み値を表示する。\n/// SUB_PAIRS に銘柄を足すと価格と 24 時間変化率のコンパクト行を下に追加できる。\n/// 板の厚みバーで買い圧/売り圧が一目で分かる、参考価格サイト (CoinGecko 等) には\n/// 出せない「実際に約定する価格」を見せる取引所ならではのレイアウト。\n/// 銘柄を変えるには下の FOCUS_PAIR / SUB_PAIRS を直接編集する (OKJ 取扱の JPY ペアのみ)。\n/// FOCUS_PAIR: 板・歩み値まで表示する基軸通貨コード (例: ETH)\n/// SUB_PAIRS : 価格のみ表示する基軸通貨コードのカンマ区切り (例: BTC,SOL,XRP)。空なら非表示\n\nlet FOCUS_PAIR = \"ETH\"\nlet SUB_PAIRS = \"\"\nlet WIDTH = 24 // monospace 整列の桁幅 (半角換算)\nlet BOOK_LEVELS = 3 // 板の表示段数 (片側)\nlet BAR_CELLS = 6 // 板の厚みバーの最大幅 (文字数)\n\nvar lastStatus = 0 // 直近の HTTP ステータス (エラー表示用)\n\n// CoinMarketCap 風カラー (crypto ウィジェットと同一)\nlet COL_UP = \"16c784\"\nlet COL_DOWN = \"ea3943\"\nlet COL_FLAT = \"8a919e\"\nlet COL_DIM = \"5b6470\"\nlet COL_NAME = \"eff2f5\"\nlet COL_PRICE = \"d0d5dd\"\n\n// ─────────────────────────────\n// ヘルパー\n// ─────────────────────────────\n@dflt(v, d) { if (Core:type(v) == \"null\") d else v }\n\n@get(o, k, d) {\n if (Core:type(o) != \"obj\") { d }\n elif (Obj:has(o, k)) { dflt(o[k], d) }\n else { d }\n}\n\n/// OKJ API は数値を文字列で返すので数値化する。失敗時 null。\n@toNum(s) {\n if (Core:type(s) == \"num\") { return s }\n if (Core:type(s) != \"str\" || s == \"\") { return null }\n let v = Json:parse(s)\n if (Core:type(v) == \"num\") { v } else { null }\n}\n\n@round2(x) {\n if (Core:type(x) == \"null\") { 0 }\n else { Math:round(x * 100) / 100 }\n}\n\n@pad2(n) { if (n < 10) { `0{n}` } else { `{n}` } }\n\n/// 同じ文字を n 個並べた文字列\n@repeatStr(ch, n) {\n var s = \"\"\n if (n > 0) { for (let i, n) { s = `{s}{ch}` } }\n s\n}\n\n/// 左セグメントと右セグメントを幅 w に揃える (間をスペース埋め)\n/// lPlain/rPlain は幅計算用の素テキスト、lShow/rShow は装飾込みの表示テキスト\n@justify(lPlain, lShow, rPlain, rShow, w) {\n let gap = w - lPlain.len - rPlain.len\n `{lShow}{repeatStr(\" \", if (gap > 0) { gap } else { 1 })}{rShow}`\n}\n\n/// 非負整数に3桁区切りのカンマを挿入 (再帰)\n@addCommas(n) {\n if (n < 1000) { `{n}` }\n else {\n let head = addCommas(Math:floor(n / 1000))\n let tail = n % 1000\n let t = if (tail < 10) { `00{tail}` } elif (tail < 100) { `0{tail}` } else { `{tail}` }\n `{head},{t}`\n }\n}\n\n/// 円建て価格を整形 (価格帯に応じて桁数を調整)\n@fmtPrice(p) {\n if (Core:type(p) == \"null\") { \"¥-\" }\n elif (p >= 100) { `¥{addCommas(Math:round(p))}` }\n elif (p >= 1) { `¥{round2(p)}` }\n else { `¥{Math:round(p * 10000) / 10000}` }\n}\n\n/// 歩み値用の短縮価格。100万以上は下 6 桁だけ見せて横幅を稼ぐ\n/// (板に全桁が出ているので上位桁は自明)。\n@tapePrice(p) {\n if (Core:type(p) == \"null\") { \"-\" }\n elif (p >= 1000000) { addCommas(Math:round(p) % 1000000) }\n else { addCommas(Math:round(p)) }\n}\n\n/// 基軸通貨コード → 通貨を表す絵文字 (おおむねブランドカラー)。\n/// 登録のある銘柄だけ表示し、未登録は記号なし。\n@symbolFor(code) {\n if (code == \"BTC\") { \"🟠\" }\n elif (code == \"ETH\") { \"🔷\" }\n elif (code == \"SOL\") { \"🟣\" }\n elif (code == \"XRP\") { \"⚫\" }\n elif (code == \"DOGE\") { \"🐕\" }\n elif (code == \"ADA\") { \"🔵\" }\n elif (code == \"LTC\") { \"⚪\" }\n elif (code == \"DOT\") { \"🔴\" }\n elif (code == \"TRX\") { \"🔻\" }\n elif (code == \"AVAX\") { \"🔺\" }\n else { \"\" }\n}\n\n@chgArrow(chg) {\n if (Core:type(chg) == \"null\") { \"■\" }\n elif (chg > 0) { \"▲\" }\n elif (chg < 0) { \"▼\" }\n else { \"■\" }\n}\n\n@chgColor(chg) {\n if (Core:type(chg) == \"null\") { COL_FLAT }\n elif (chg > 0) { COL_UP }\n elif (chg < 0) { COL_DOWN }\n else { COL_FLAT }\n}\n\n/// 数量を板の厚みバー (1/8 ブロック刻み) に変換。mx は表示中の最大数量。\n@depthBar(size, mx) {\n if (Core:type(size) != \"num\" || Core:type(mx) != \"num\") { return \"▏\" }\n if (mx <= 0 || size <= 0) { return \"▏\" }\n var e = Math:round(size / mx * BAR_CELLS * 8)\n if (e < 1) { e = 1 }\n let full = Math:floor(e / 8)\n let rem = e % 8\n let partials = [\"\", \"▏\", \"▎\", \"▍\", \"▌\", \"▋\", \"▊\", \"▉\"]\n `{repeatStr(\"█\", full)}{partials[rem]}`\n}\n\n// ─────────────────────────────\n// データ取得\n// ─────────────────────────────\n/// 全 JPY ペアの ticker を 1 リクエストで取得\n@fetchTickers() {\n let res = Nd:http(\"https://www.okcoin.jp/api/spot/v3/instruments/ticker\", {\n method: \"GET\"\n headers: { \"Accept\": \"application/json\" }\n })\n lastStatus = res.status\n if (res.status != 200) { return null }\n Json:parse(res.body)\n}\n\n/// 板 (best からの BOOK_LEVELS 段×2)。失敗時 null。\n/// asks/bids とも best が先頭。各段は { price, size } に正規化する。\n@fetchBook(code) {\n let res = Nd:http(`https://www.okcoin.jp/api/spot/v3/instruments/{code}-JPY/book?size={BOOK_LEVELS}`, {\n method: \"GET\"\n headers: { \"Accept\": \"application/json\" }\n })\n if (res.status != 200) { return null }\n let body = Json:parse(res.body)\n if (Core:type(body) != \"obj\") { return null }\n @rows(side) {\n let raw = get(body, side, [])\n if (Core:type(raw) != \"arr\") { return [] }\n var lst = []\n for (let i, raw.len) {\n let row = raw[i]\n if (Core:type(row) == \"arr\" && row.len >= 2) {\n let p = toNum(row[0])\n let s = toNum(row[1])\n if (Core:type(p) == \"num\" && Core:type(s) == \"num\") { lst.push({ price: p, size: s }) }\n }\n }\n lst\n }\n { asks: rows(\"asks\"), bids: rows(\"bids\") }\n}\n\n/// 直近の歩み値 (新しい順に最大 3 件)。失敗時は空配列。\n@fetchTrades(code) {\n let res = Nd:http(`https://www.okcoin.jp/api/spot/v3/instruments/{code}-JPY/trades?limit=3`, {\n method: \"GET\"\n headers: { \"Accept\": \"application/json\" }\n })\n if (res.status != 200) { return [] }\n let rows = Json:parse(res.body)\n if (Core:type(rows) != \"arr\") { return [] }\n var lst = []\n for (let i, rows.len) {\n if (lst.len < 3) {\n let p = toNum(get(rows[i], \"price\", null))\n if (Core:type(p) == \"num\") { lst.push({ price: p, side: get(rows[i], \"side\", \"\") }) }\n }\n }\n lst\n}\n\n/// ticker 配列から指定ペアを探す (見つからなければ null)\n@findTicker(arr, instId) {\n if (Core:type(arr) != \"arr\") { return null }\n var found = null\n for (let i, arr.len) {\n if (get(arr[i], \"instrument_id\", \"\") == instId) { found = arr[i] }\n }\n found\n}\n\n/// 24時間変化率 (%) を open_24h と last から算出。算出不能なら null。\n@chg24h(t) {\n let last = toNum(get(t, \"last\", null))\n let open = toNum(get(t, \"open_24h\", null))\n if (Core:type(last) != \"num\" || Core:type(open) != \"num\") { return null }\n if (open == 0) { return null }\n (last - open) / open * 100\n}\n\n// ─────────────────────────────\n// UI 構築\n// ─────────────────────────────\n/// フォーカス銘柄のヘッダ 2 行 (ペア名+更新時刻 / 現在値+変化率)\n@focusHeader(code, ticker) {\n let now = Date:now()\n let chg = chg24h(ticker)\n let col = chgColor(chg)\n let chgV = if (Core:type(chg) == \"null\") { \"-\" } else { `{round2(chg)}` }\n let price = toNum(get(ticker, \"last\", null))\n [\n Ui:C:mfm({ text: `**OKJ {code}-JPY** $[fg.color={COL_DIM} 更新 {pad2(Date:hour(now))}:{pad2(Date:minute(now))}]` })\n Ui:C:mfm({ text: `$[fg.color={COL_NAME} **{fmtPrice(price)}**] $[fg.color={col} {chgArrow(chg)}{chgV}%]` })\n ]\n}\n\n/// 板 1 段。side は \"売\"/\"買\"、col は段の色。\n@bookLine(label, col, lvl, mx) {\n Ui:C:mfm({\n text: ` $[fg.color={col} {label} {addCommas(Math:round(lvl.price))} {depthBar(lvl.size, mx)}]`\n font: \"monospace\"\n })\n}\n\n/// 板 (売り3段 → スプレッド → 買い3段) を組み立てる。book が null なら省略。\n@bookView(book) {\n if (Core:type(book) != \"obj\") { return [] }\n let asks = book.asks\n let bids = book.bids\n if (asks.len == 0 || bids.len == 0) { return [] }\n\n // バーの正規化基準は表示中の全段の最大数量\n var mx = 0\n for (let i, asks.len) { if (asks[i].size > mx) { mx = asks[i].size } }\n for (let i, bids.len) { if (bids[i].size > mx) { mx = bids[i].size } }\n\n var body = []\n // 売り板は遠い順に並べ、best ask がスプレッド行に隣接するようにする\n for (let i, asks.len) {\n body.push(bookLine(\"売\", COL_DOWN, asks[asks.len - 1 - i], mx))\n }\n\n let spread = asks[0].price - bids[0].price\n let mid = (asks[0].price + bids[0].price) / 2\n let pct = if (mid == 0) { 0 } else { round2(spread / mid * 100) }\n body.push(Ui:C:mfm({\n text: ` $[fg.color={COL_DIM} ─ スプレッド ¥{addCommas(Math:round(spread))} {pct}% ─]`\n font: \"monospace\"\n }))\n\n for (let i, bids.len) {\n body.push(bookLine(\"買\", COL_UP, bids[i], mx))\n }\n body\n}\n\n/// 歩み値 1 行 (新しい順 3 件、買い↑緑/売り↓赤)。空なら省略。\n@tapeView(trades) {\n if (trades.len == 0) { return [] }\n var parts = \"\"\n for (let i, trades.len) {\n let t = trades[i]\n let up = t.side == \"buy\"\n let mark = if (up) { \"↑\" } else { \"↓\" }\n let col = if (up) { COL_UP } else { COL_DOWN }\n parts = `{parts} $[fg.color={col} {mark}{tapePrice(t.price)}]`\n }\n [Ui:C:mfm({ text: ` $[fg.color={COL_DIM} 歩み]{parts}`, font: \"monospace\" })]\n}\n\n/// サブ銘柄のコンパクト行 (コード … 価格 変化率)\n@subLine(code, ticker) {\n if (Core:type(ticker) == \"null\") {\n return Ui:C:mfm({ text: ` $[fg.color={COL_FLAT} {code} — データなし]`, font: \"monospace\" })\n }\n let sym = symbolFor(code)\n let name = if (sym != \"\") { `{sym} {code}` } else { code }\n let price = toNum(get(ticker, \"last\", null))\n let chg = chg24h(ticker)\n let col = chgColor(chg)\n let chgV = if (Core:type(chg) == \"null\") { \"-\" } else { `{round2(chg)}` }\n\n let lPlain = ` {name}`\n let lShow = ` $[fg.color={COL_NAME} **{name}**]`\n let rPlain = `{fmtPrice(price)} {chgArrow(chg)}{chgV}%`\n let rShow = `$[fg.color={COL_PRICE} {fmtPrice(price)}] $[fg.color={col} {chgArrow(chg)}{chgV}%]`\n Ui:C:mfm({ text: justify(lPlain, lShow, rPlain, rShow, WIDTH), font: \"monospace\" })\n}\n\n@boardView(tickers) {\n var body = []\n\n let focusTicker = findTicker(tickers, `{FOCUS_PAIR}-JPY`)\n if (Core:type(focusTicker) != \"null\") {\n body = body.concat(focusHeader(FOCUS_PAIR, focusTicker))\n body = body.concat(bookView(fetchBook(FOCUS_PAIR)))\n body = body.concat(tapeView(fetchTrades(FOCUS_PAIR)))\n } else {\n body.push(Ui:C:mfm({ text: `$[fg.color={COL_FLAT} {FOCUS_PAIR}-JPY — データなし]` }))\n }\n\n let codeList = SUB_PAIRS.replace(\" \", \"\").split(\",\")\n var subs = []\n for (let i, codeList.len) {\n if (codeList[i] != \"\") { subs.push(codeList[i]) }\n }\n if (subs.len > 0) {\n body.push(Ui:C:mfm({ text: ` $[fg.color={COL_DIM} {repeatStr(\"─\", WIDTH - 2)}]`, font: \"monospace\" }))\n for (let i, subs.len) {\n body.push(subLine(subs[i], findTicker(tickers, `{subs[i]}-JPY`)))\n }\n }\n body\n}\n\nvar firstRun = true\nvar hasData = false // 一度でも価格表示に成功したか\n\n@render() {\n if (firstRun) {\n Ui:render([\n Ui:C:container({\n align: \"center\"\n children: [Ui:C:text({ text: \"価格を読み込み中…\" })]\n })\n ])\n }\n firstRun = false\n\n let tickers = fetchTickers()\n if (Core:type(tickers) != \"arr\") {\n // 取得失敗 (一時的な障害など)。既に表示があればそれを維持し、\n // 画面のチラつきを防ぐ。初回失敗時のみエラーを表示する。\n if (hasData) { return null }\n Ui:render([\n Ui:C:container({\n align: \"center\"\n children: [\n Ui:C:mfm({ text: `$[fg.color=ea3943 ⚠️ 価格データを取得できません (HTTP {lastStatus})]` })\n Ui:C:mfm({ text: `$[fg.color={COL_FLAT} 通信エラー。しばらく待つと回復します]` })\n ]\n })\n ])\n return null\n }\n hasData = true\n\n let children = boardView(tickers)\n Ui:render([Ui:C:container({ align: \"center\", children: children })])\n}\n\n// 認証不要の公開 API。1 更新 = ticker + book + trades の 3 リクエスト。\n// 板と歩み値は鮮度が命なので 60 秒間隔 (3 req/min) で回す。\n// 取得失敗時は前回表示を維持する (hasData によるチラ防止)。\nrender()\nAsync:interval(60000, render, false)\n"}
+{"type":"widget","data":"/// @ 1.2.1\n/// OKJ 円建て価格ボードウィジェット (ハイブリッド型)\n/// OKCoinJapan (OKJ) の公開 API で国内取引所のリアルな売買データを表示。API キー不要。\n/// フォーカス銘柄 (既定 ETH) の板 3 段×2 + スプレッド + 歩み値を表示する。\n/// SUB_PAIRS に銘柄を足すと価格と 24 時間変化率のコンパクト行を下に追加できる。\n/// 板の厚みバーで買い圧/売り圧が一目で分かる、参考価格サイト (CoinGecko 等) には\n/// 出せない「実際に約定する価格」を見せる取引所ならではのレイアウト。\n/// 銘柄を変えるには下の FOCUS_PAIR / SUB_PAIRS を直接編集する (OKJ 取扱の JPY ペアのみ)。\n/// FOCUS_PAIR: 板・歩み値まで表示する基軸通貨コード (例: ETH)\n/// SUB_PAIRS : 価格のみ表示する基軸通貨コードのカンマ区切り (例: BTC,SOL,XRP)。空なら非表示\n\nlet FOCUS_PAIR = \"ETH\"\nlet SUB_PAIRS = \"\"\nlet WIDTH = 24 // monospace 整列の桁幅 (半角換算)\nlet BOOK_LEVELS = 3 // 板の表示段数 (片側)\nlet BAR_CELLS = 6 // 板の厚みバーの最大幅 (文字数)\n\nvar lastStatus = 0 // 直近の HTTP ステータス (エラー表示用)\n/// Nd:http の失敗理由。status 0 は通信断 (DNS 失敗・接続拒否・タイムアウト) で、\n/// NoteDeck は throw せず値で返す (notedeck#1294)\n@httpLabel(st) { if (st == 0) { \"通信できません\" } else { `HTTP {st}` } }\n\n// CoinMarketCap 風カラー (crypto ウィジェットと同一)\nlet COL_UP = \"16c784\"\nlet COL_DOWN = \"ea3943\"\nlet COL_FLAT = \"8a919e\"\nlet COL_DIM = \"5b6470\"\nlet COL_NAME = \"eff2f5\"\nlet COL_PRICE = \"d0d5dd\"\n\n// ─────────────────────────────\n// ヘルパー\n// ─────────────────────────────\n@dflt(v, d) { if (Core:type(v) == \"null\") d else v }\n\n@get(o, k, d) {\n if (Core:type(o) != \"obj\") { d }\n elif (Obj:has(o, k)) { dflt(o[k], d) }\n else { d }\n}\n\n/// OKJ API は数値を文字列で返すので数値化する。失敗時 null。\n@toNum(s) {\n if (Core:type(s) == \"num\") { return s }\n if (Core:type(s) != \"str\" || s == \"\") { return null }\n let v = Json:parse(s)\n if (Core:type(v) == \"num\") { v } else { null }\n}\n\n@round2(x) {\n if (Core:type(x) == \"null\") { 0 }\n else { Math:round(x * 100) / 100 }\n}\n\n@pad2(n) { if (n < 10) { `0{n}` } else { `{n}` } }\n\n/// 同じ文字を n 個並べた文字列\n@repeatStr(ch, n) {\n var s = \"\"\n if (n > 0) { for (let i, n) { s = `{s}{ch}` } }\n s\n}\n\n/// 左セグメントと右セグメントを幅 w に揃える (間をスペース埋め)\n/// lPlain/rPlain は幅計算用の素テキスト、lShow/rShow は装飾込みの表示テキスト\n@justify(lPlain, lShow, rPlain, rShow, w) {\n let gap = w - lPlain.len - rPlain.len\n `{lShow}{repeatStr(\" \", if (gap > 0) { gap } else { 1 })}{rShow}`\n}\n\n/// 非負整数に3桁区切りのカンマを挿入 (再帰)\n@addCommas(n) {\n if (n < 1000) { `{n}` }\n else {\n let head = addCommas(Math:floor(n / 1000))\n let tail = n % 1000\n let t = if (tail < 10) { `00{tail}` } elif (tail < 100) { `0{tail}` } else { `{tail}` }\n `{head},{t}`\n }\n}\n\n/// 円建て価格を整形 (価格帯に応じて桁数を調整)\n@fmtPrice(p) {\n if (Core:type(p) == \"null\") { \"¥-\" }\n elif (p >= 100) { `¥{addCommas(Math:round(p))}` }\n elif (p >= 1) { `¥{round2(p)}` }\n else { `¥{Math:round(p * 10000) / 10000}` }\n}\n\n/// 歩み値用の短縮価格。100万以上は下 6 桁だけ見せて横幅を稼ぐ\n/// (板に全桁が出ているので上位桁は自明)。\n@tapePrice(p) {\n if (Core:type(p) == \"null\") { \"-\" }\n elif (p >= 1000000) { addCommas(Math:round(p) % 1000000) }\n else { addCommas(Math:round(p)) }\n}\n\n/// 基軸通貨コード → 通貨を表す絵文字 (おおむねブランドカラー)。\n/// 登録のある銘柄だけ表示し、未登録は記号なし。\n@symbolFor(code) {\n if (code == \"BTC\") { \"🟠\" }\n elif (code == \"ETH\") { \"🔷\" }\n elif (code == \"SOL\") { \"🟣\" }\n elif (code == \"XRP\") { \"⚫\" }\n elif (code == \"DOGE\") { \"🐕\" }\n elif (code == \"ADA\") { \"🔵\" }\n elif (code == \"LTC\") { \"⚪\" }\n elif (code == \"DOT\") { \"🔴\" }\n elif (code == \"TRX\") { \"🔻\" }\n elif (code == \"AVAX\") { \"🔺\" }\n else { \"\" }\n}\n\n@chgArrow(chg) {\n if (Core:type(chg) == \"null\") { \"■\" }\n elif (chg > 0) { \"▲\" }\n elif (chg < 0) { \"▼\" }\n else { \"■\" }\n}\n\n@chgColor(chg) {\n if (Core:type(chg) == \"null\") { COL_FLAT }\n elif (chg > 0) { COL_UP }\n elif (chg < 0) { COL_DOWN }\n else { COL_FLAT }\n}\n\n/// 数量を板の厚みバー (1/8 ブロック刻み) に変換。mx は表示中の最大数量。\n@depthBar(size, mx) {\n if (Core:type(size) != \"num\" || Core:type(mx) != \"num\") { return \"▏\" }\n if (mx <= 0 || size <= 0) { return \"▏\" }\n var e = Math:round(size / mx * BAR_CELLS * 8)\n if (e < 1) { e = 1 }\n let full = Math:floor(e / 8)\n let rem = e % 8\n let partials = [\"\", \"▏\", \"▎\", \"▍\", \"▌\", \"▋\", \"▊\", \"▉\"]\n `{repeatStr(\"█\", full)}{partials[rem]}`\n}\n\n// ─────────────────────────────\n// データ取得\n// ─────────────────────────────\n/// 全 JPY ペアの ticker を 1 リクエストで取得\n@fetchTickers() {\n let res = Nd:http(\"https://www.okcoin.jp/api/spot/v3/instruments/ticker\", {\n method: \"GET\"\n headers: { \"Accept\": \"application/json\" }\n })\n lastStatus = res.status\n if (res.status != 200) { return null }\n Json:parse(res.body)\n}\n\n/// 板 (best からの BOOK_LEVELS 段×2)。失敗時 null。\n/// asks/bids とも best が先頭。各段は { price, size } に正規化する。\n@fetchBook(code) {\n let res = Nd:http(`https://www.okcoin.jp/api/spot/v3/instruments/{code}-JPY/book?size={BOOK_LEVELS}`, {\n method: \"GET\"\n headers: { \"Accept\": \"application/json\" }\n })\n if (res.status != 200) { return null }\n let body = Json:parse(res.body)\n if (Core:type(body) != \"obj\") { return null }\n @rows(side) {\n let raw = get(body, side, [])\n if (Core:type(raw) != \"arr\") { return [] }\n var lst = []\n for (let i, raw.len) {\n let row = raw[i]\n if (Core:type(row) == \"arr\" && row.len >= 2) {\n let p = toNum(row[0])\n let s = toNum(row[1])\n if (Core:type(p) == \"num\" && Core:type(s) == \"num\") { lst.push({ price: p, size: s }) }\n }\n }\n lst\n }\n { asks: rows(\"asks\"), bids: rows(\"bids\") }\n}\n\n/// 直近の歩み値 (新しい順に最大 3 件)。失敗時は空配列。\n@fetchTrades(code) {\n let res = Nd:http(`https://www.okcoin.jp/api/spot/v3/instruments/{code}-JPY/trades?limit=3`, {\n method: \"GET\"\n headers: { \"Accept\": \"application/json\" }\n })\n if (res.status != 200) { return [] }\n let rows = Json:parse(res.body)\n if (Core:type(rows) != \"arr\") { return [] }\n var lst = []\n for (let i, rows.len) {\n if (lst.len < 3) {\n let p = toNum(get(rows[i], \"price\", null))\n if (Core:type(p) == \"num\") { lst.push({ price: p, side: get(rows[i], \"side\", \"\") }) }\n }\n }\n lst\n}\n\n/// ticker 配列から指定ペアを探す (見つからなければ null)\n@findTicker(arr, instId) {\n if (Core:type(arr) != \"arr\") { return null }\n var found = null\n for (let i, arr.len) {\n if (get(arr[i], \"instrument_id\", \"\") == instId) { found = arr[i] }\n }\n found\n}\n\n/// 24時間変化率 (%) を open_24h と last から算出。算出不能なら null。\n@chg24h(t) {\n let last = toNum(get(t, \"last\", null))\n let open = toNum(get(t, \"open_24h\", null))\n if (Core:type(last) != \"num\" || Core:type(open) != \"num\") { return null }\n if (open == 0) { return null }\n (last - open) / open * 100\n}\n\n// ─────────────────────────────\n// UI 構築\n// ─────────────────────────────\n/// フォーカス銘柄のヘッダ 2 行 (ペア名+更新時刻 / 現在値+変化率)\n@focusHeader(code, ticker) {\n let now = Date:now()\n let chg = chg24h(ticker)\n let col = chgColor(chg)\n let chgV = if (Core:type(chg) == \"null\") { \"-\" } else { `{round2(chg)}` }\n let price = toNum(get(ticker, \"last\", null))\n [\n Ui:C:mfm({ text: `**OKJ {code}-JPY** $[fg.color={COL_DIM} 更新 {pad2(Date:hour(now))}:{pad2(Date:minute(now))}]` })\n Ui:C:mfm({ text: `$[fg.color={COL_NAME} **{fmtPrice(price)}**] $[fg.color={col} {chgArrow(chg)}{chgV}%]` })\n ]\n}\n\n/// 板 1 段。side は \"売\"/\"買\"、col は段の色。\n@bookLine(label, col, lvl, mx) {\n Ui:C:mfm({\n text: ` $[fg.color={col} {label} {addCommas(Math:round(lvl.price))} {depthBar(lvl.size, mx)}]`\n font: \"monospace\"\n })\n}\n\n/// 板 (売り3段 → スプレッド → 買い3段) を組み立てる。book が null なら省略。\n@bookView(book) {\n if (Core:type(book) != \"obj\") { return [] }\n let asks = book.asks\n let bids = book.bids\n if (asks.len == 0 || bids.len == 0) { return [] }\n\n // バーの正規化基準は表示中の全段の最大数量\n var mx = 0\n for (let i, asks.len) { if (asks[i].size > mx) { mx = asks[i].size } }\n for (let i, bids.len) { if (bids[i].size > mx) { mx = bids[i].size } }\n\n var body = []\n // 売り板は遠い順に並べ、best ask がスプレッド行に隣接するようにする\n for (let i, asks.len) {\n body.push(bookLine(\"売\", COL_DOWN, asks[asks.len - 1 - i], mx))\n }\n\n let spread = asks[0].price - bids[0].price\n let mid = (asks[0].price + bids[0].price) / 2\n let pct = if (mid == 0) { 0 } else { round2(spread / mid * 100) }\n body.push(Ui:C:mfm({\n text: ` $[fg.color={COL_DIM} ─ スプレッド ¥{addCommas(Math:round(spread))} {pct}% ─]`\n font: \"monospace\"\n }))\n\n for (let i, bids.len) {\n body.push(bookLine(\"買\", COL_UP, bids[i], mx))\n }\n body\n}\n\n/// 歩み値 1 行 (新しい順 3 件、買い↑緑/売り↓赤)。空なら省略。\n@tapeView(trades) {\n if (trades.len == 0) { return [] }\n var parts = \"\"\n for (let i, trades.len) {\n let t = trades[i]\n let up = t.side == \"buy\"\n let mark = if (up) { \"↑\" } else { \"↓\" }\n let col = if (up) { COL_UP } else { COL_DOWN }\n parts = `{parts} $[fg.color={col} {mark}{tapePrice(t.price)}]`\n }\n [Ui:C:mfm({ text: ` $[fg.color={COL_DIM} 歩み]{parts}`, font: \"monospace\" })]\n}\n\n/// サブ銘柄のコンパクト行 (コード … 価格 変化率)\n@subLine(code, ticker) {\n if (Core:type(ticker) == \"null\") {\n return Ui:C:mfm({ text: ` $[fg.color={COL_FLAT} {code} — データなし]`, font: \"monospace\" })\n }\n let sym = symbolFor(code)\n let name = if (sym != \"\") { `{sym} {code}` } else { code }\n let price = toNum(get(ticker, \"last\", null))\n let chg = chg24h(ticker)\n let col = chgColor(chg)\n let chgV = if (Core:type(chg) == \"null\") { \"-\" } else { `{round2(chg)}` }\n\n let lPlain = ` {name}`\n let lShow = ` $[fg.color={COL_NAME} **{name}**]`\n let rPlain = `{fmtPrice(price)} {chgArrow(chg)}{chgV}%`\n let rShow = `$[fg.color={COL_PRICE} {fmtPrice(price)}] $[fg.color={col} {chgArrow(chg)}{chgV}%]`\n Ui:C:mfm({ text: justify(lPlain, lShow, rPlain, rShow, WIDTH), font: \"monospace\" })\n}\n\n@boardView(tickers) {\n var body = []\n\n let focusTicker = findTicker(tickers, `{FOCUS_PAIR}-JPY`)\n if (Core:type(focusTicker) != \"null\") {\n body = body.concat(focusHeader(FOCUS_PAIR, focusTicker))\n body = body.concat(bookView(fetchBook(FOCUS_PAIR)))\n body = body.concat(tapeView(fetchTrades(FOCUS_PAIR)))\n } else {\n body.push(Ui:C:mfm({ text: `$[fg.color={COL_FLAT} {FOCUS_PAIR}-JPY — データなし]` }))\n }\n\n let codeList = SUB_PAIRS.replace(\" \", \"\").split(\",\")\n var subs = []\n for (let i, codeList.len) {\n if (codeList[i] != \"\") { subs.push(codeList[i]) }\n }\n if (subs.len > 0) {\n body.push(Ui:C:mfm({ text: ` $[fg.color={COL_DIM} {repeatStr(\"─\", WIDTH - 2)}]`, font: \"monospace\" }))\n for (let i, subs.len) {\n body.push(subLine(subs[i], findTicker(tickers, `{subs[i]}-JPY`)))\n }\n }\n body\n}\n\nvar firstRun = true\nvar hasData = false // 一度でも価格表示に成功したか\n\n@render() {\n if (firstRun) {\n Ui:render([\n Ui:C:container({\n align: \"center\"\n children: [Ui:C:text({ text: \"価格を読み込み中…\" })]\n })\n ])\n }\n firstRun = false\n\n let tickers = fetchTickers()\n if (Core:type(tickers) != \"arr\") {\n // 取得失敗 (一時的な障害など)。既に表示があればそれを維持し、\n // 画面のチラつきを防ぐ。初回失敗時のみエラーを表示する。\n if (hasData) { return null }\n Ui:render([\n Ui:C:container({\n align: \"center\"\n children: [\n Ui:C:mfm({ text: `$[fg.color=ea3943 ⚠️ 価格データを取得できません ({httpLabel(lastStatus)})]` })\n Ui:C:mfm({ text: `$[fg.color={COL_FLAT} 通信エラー。しばらく待つと回復します]` })\n ]\n })\n ])\n return null\n }\n hasData = true\n\n let children = boardView(tickers)\n Ui:render([Ui:C:container({ align: \"center\", children: children })])\n}\n\n// 認証不要の公開 API。1 更新 = ticker + book + trades の 3 リクエスト。\n// 板と歩み値は鮮度が命なので 60 秒間隔 (3 req/min) で回す。\n// 取得失敗時は前回表示を維持する (hasData によるチラ防止)。\nrender()\nAsync:interval(60000, render, false)\n"}
diff --git a/public/registry/widgets/okj-prices/widget.is b/public/registry/widgets/okj-prices/widget.is
index 448c93f..eea0553 100644
--- a/public/registry/widgets/okj-prices/widget.is
+++ b/public/registry/widgets/okj-prices/widget.is
@@ -16,6 +16,9 @@ let BOOK_LEVELS = 3 // 板の表示段数 (片側)
let BAR_CELLS = 6 // 板の厚みバーの最大幅 (文字数)
var lastStatus = 0 // 直近の HTTP ステータス (エラー表示用)
+/// Nd:http の失敗理由。status 0 は通信断 (DNS 失敗・接続拒否・タイムアウト) で、
+/// NoteDeck は throw せず値で返す (notedeck#1294)
+@httpLabel(st) { if (st == 0) { "通信できません" } else { `HTTP {st}` } }
// CoinMarketCap 風カラー (crypto ウィジェットと同一)
let COL_UP = "16c784"
@@ -351,7 +354,7 @@ var hasData = false // 一度でも価格表示に成功したか
Ui:C:container({
align: "center"
children: [
- Ui:C:mfm({ text: `$[fg.color=ea3943 ⚠️ 価格データを取得できません (HTTP {lastStatus})]` })
+ Ui:C:mfm({ text: `$[fg.color=ea3943 ⚠️ 価格データを取得できません ({httpLabel(lastStatus)})]` })
Ui:C:mfm({ text: `$[fg.color={COL_FLAT} 通信エラー。しばらく待つと回復します]` })
]
})
diff --git a/public/registry/widgets/osv-vulns/api.json b/public/registry/widgets/osv-vulns/api.json
index 3a62846..e974ae1 100644
--- a/public/registry/widgets/osv-vulns/api.json
+++ b/public/registry/widgets/osv-vulns/api.json
@@ -1 +1 @@
-{"type":"widget","data":"/// @ 1.2.1\n/// OSV 脆弱性ウィジェット\n/// OSV.dev (Google の Open Source Vulnerabilities) に**パッケージ名**を投げて、\n/// そのパッケージの既知脆弱性を新しい順に表示する。API キー不要。\n/// 既定は Misskey の公式 JS SDK (npm:misskey-js)。\n///\n/// cve ウィジェットが「NVD の直近 N 日の CVE を眺める」= CVE 番号起点なのに対し、\n/// こちらは「自分が使っているパッケージに何が出ているか」= パッケージ起点で引く。\n///\n/// 実装メモ:\n/// - POST https://api.osv.dev/v1/query に {\"package\":{\"name\",\"ecosystem\"}} を送る。\n/// 本文は Json:stringify で組む (パッケージ名を文字列連結すると本文を捏造できる)。\n/// 加えて validPkg で許可文字を制限し、リンク生成にも安全な値だけ通す\n/// - ecosystem の綴りは OSV 側で厳密 (\"PyPI\" / \"crates.io\" / \"RubyGems\")。select で固定する\n/// - 0 件のときの応答は {} (vulns キーごと無い)\n/// - 大きなパッケージほど応答が重い (npm:next で約 160KB)。表示は MAX_ITEMS 件だけ\n/// - OSV の返す順は発行日順ではないので published から数値キーを作って並べ替える\n\nlet API_URL = \"https://api.osv.dev/v1/query\"\nlet PKG_KEY = \"osv-vulns:pkg\"\nlet ECO_KEY = \"osv-vulns:eco\"\nlet DEFAULT_PKG = \"misskey-js\"\nlet DEFAULT_ECO = \"npm\"\nlet MAX_ITEMS = 5 // 表示する最大件数\nlet DESC_LEN = 58 // 概要の最大表示文字数\nlet MAX_NAME = 128 // パッケージ名の最大長\n// 応答は件数に比例して重い (npm:next で約 160KB、PyPI:Django は 320 件で約 870KB)。\n// 桁違いのものはパースせず捨てる\nlet MAX_BODY = 1200000\n// パッケージ名に許す文字。\" と \\ を除くことで POST 本文を捏造されないようにする\nlet PKG_CHARS = \"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.-_/@:+\"\n\n// cve ウィジェットと揃えたカラー\nlet COL_DIM = \"5b6470\"\nlet COL_FLAT = \"8a919e\"\nlet COL_DESC = \"b0b7c3\"\nlet COL_OK = \"16c784\"\n\n// ─────────────────────────────\n// ヘルパー\n// ─────────────────────────────\n@dflt(v, d) { if (Core:type(v) == \"null\") d else v }\n\n@get(o, k, d) {\n if (Core:type(o) != \"obj\") { d }\n elif (Obj:has(o, k)) { dflt(o[k], d) }\n else { d }\n}\n\n/// レスポンス本文を JSON オブジェクトとして読む。\n/// Json:parse は失敗時に throw せず error 値を返すので、obj 以外はすべて null に潰す\n@parseObj(body) {\n if (Core:type(body) != \"str\") { return null }\n if (body.len > MAX_BODY) { return null }\n let v = Json:parse(body)\n if (Core:type(v) == \"obj\") v else null\n}\n\n/// OSV 由来の文字列を表示用に無害化する。\n/// MFM ($[...])・リンク ([...])・装飾 (*)・HTML (<>) を全角に置換して発火させない\n@safeLabel(s) {\n if (Core:type(s) != \"str\") { \"?\" }\n else {\n s.replace(\"$\", \"$\").replace(\"[\", \"[\").replace(\"]\", \"]\").replace(\"*\", \"*\").replace(\"<\", \"<\").replace(\">\", \">\")\n }\n}\n\n@trunc(s, n) {\n if (s.len > n) `{s.slice(0, n)}…` else s\n}\n\n/// パッケージ名として妥当か (許可文字のみ・長さ制限)\n@validPkg(s) {\n if (Core:type(s) != \"str\") { return false }\n if (s == \"\" || s.len > MAX_NAME) { return false }\n var ok = true\n each (let c, s.to_char_arr()) {\n if (!PKG_CHARS.incl(c)) { ok = false }\n }\n ok\n}\n\n/// 入力欄の値をパッケージ名に整える。妥当でなければ null\n@normalizePkg(s) {\n if (Core:type(s) != \"str\") { return null }\n let p = s.trim()\n if (!validPkg(p)) { return null }\n p\n}\n\n/// GHSA の重大度 (LOW / MODERATE / HIGH / CRITICAL)。無ければ \"不明\"\n@sevOf(v) {\n let s = get(get(v, \"database_specific\", null), \"severity\", null)\n if (Core:type(s) != \"str\" || s == \"\") \"不明\" else s.upper()\n}\n\n@sevColor(sev) {\n if (sev == \"CRITICAL\") { \"ea3943\" }\n elif (sev == \"HIGH\") { \"ff8c42\" }\n elif (sev == \"MODERATE\" || sev == \"MEDIUM\") { \"f5a623\" }\n elif (sev == \"LOW\") { COL_OK }\n else { COL_FLAT }\n}\n\n/// ISO 8601 から \"MM/DD\" を取り出す\n@monthDay(s) {\n if (Core:type(s) != \"str\" || s.len < 10) \"\" else `{s.slice(5, 7)}/{s.slice(8, 10)}`\n}\n\n/// published から並べ替え用の数値キー (20251215205527)。読めなければ 0。\n/// 同じ日に複数出ることがある (npm:next は 1 日に何件も) ので秒まで見る。\n/// 桁の切り出しに 1 文字ずつのループを使うと 300 件規模で数秒かかるので to_num に任せる\n@pubKey(v) {\n let s = get(v, \"published\", null)\n if (Core:type(s) != \"str\" || s.len < 19) { return 0 }\n let digits = `{s.slice(0, 4)}{s.slice(5, 7)}{s.slice(8, 10)}{s.slice(11, 13)}{s.slice(14, 16)}{s.slice(17, 19)}`\n dflt(digits.to_num(), 0)\n}\n\n/// affected[].ranges[].events[] から最初の fixed バージョンを拾う。無ければ \"\"\n@fixedVersion(v) {\n var found = \"\"\n each (let a, get(v, \"affected\", [])) {\n each (let r, get(a, \"ranges\", [])) {\n each (let e, get(r, \"events\", [])) {\n let f = get(e, \"fixed\", null)\n if (found == \"\" && Core:type(f) == \"str\") { found = f }\n }\n }\n }\n found\n}\n\n/// 脆弱性 1 件 = 2 行カード\n/// 行1: ● GHSA-ID (osv.dev へのリンク) 重大度\n/// 行2: MM/DD 概要 → 修正バージョン\n@vulnRows(v) {\n let id = safeLabel(trunc(dflt(get(v, \"id\", null), \"?\"), 40))\n let sev = sevOf(v)\n let col = sevColor(sev)\n let summary = trunc(safeLabel(dflt(get(v, \"summary\", null), \"(概要なし)\")), DESC_LEN)\n var l2 = ` $[fg.color={COL_DESC} {summary}]`\n let md = monthDay(get(v, \"published\", null))\n if (md != \"\") {\n l2 = ` $[fg.color={COL_DIM} {md}] $[fg.color={COL_DESC} {summary}]`\n }\n let fixed = fixedVersion(v)\n if (fixed != \"\") {\n l2 = `{l2} $[fg.color={COL_DIM} → {safeLabel(trunc(fixed, 24))} で修正]`\n }\n [\n `$[fg.color={col} ●] [{id}](https://osv.dev/vulnerability/{id}) $[fg.color={col} **{sev}**]`\n l2\n ]\n}\n\n/// vulns を新しい順に並べ、上位 max 件ぶんの表示行を返す。\n/// 並べ替えキーは先に 1 件 1 回だけ作る。比較関数の中で pubKey を呼ぶと\n/// 件数の多いパッケージ (PyPI:Django で 320 件) で桁違いに遅くなる\n@buildRows(vulns, max) {\n if (Core:type(vulns) != \"arr\") { return [] }\n var keyed = []\n each (let v, vulns) {\n keyed.push({ k: pubKey(v), v: v })\n }\n let sorted = keyed.sort(@(a, b) { b.k - a.k })\n var rows = []\n var shown = 0\n each (let e, sorted) {\n if (shown < max) {\n rows = rows.concat(vulnRows(e.v))\n shown += 1\n }\n }\n rows\n}\n\n// ---- ここから UI 層 (ハーネス検証はこの行より上の純粋関数のみ) ----\n\n// OSV の ecosystem 名は綴りが厳密なので select で固定する\nlet ECOSYSTEMS = [\n { text: \"npm\", value: \"npm\" }\n { text: \"PyPI\", value: \"PyPI\" }\n { text: \"crates.io\", value: \"crates.io\" }\n { text: \"Go\", value: \"Go\" }\n { text: \"RubyGems\", value: \"RubyGems\" }\n { text: \"Packagist\", value: \"Packagist\" }\n { text: \"Maven\", value: \"Maven\" }\n { text: \"NuGet\", value: \"NuGet\" }\n]\n\n// 保存値も信用せず normalizePkg に通す\nvar pkg = dflt(normalizePkg(Mk:load(PKG_KEY)), DEFAULT_PKG)\nvar eco = dflt(Mk:load(ECO_KEY), DEFAULT_ECO)\nvar input = pkg\nvar firstRun = true\nvar errorMsg = null\n\n/// OSV に問い合わせる。失敗時は null を返し errorMsg に理由を入れる\n@fetchVulns(name, ecosystem) {\n errorMsg = null\n let res = Nd:http(API_URL, {\n method: \"POST\"\n headers: {\n \"Content-Type\": \"application/json\"\n \"Accept\": \"application/json\"\n }\n body: Json:stringify({\n package: {\n name: name\n ecosystem: ecosystem\n }\n })\n })\n if (res.status != 200) {\n errorMsg = `OSV に問い合わせできません (HTTP {res.status})`\n return null\n }\n let data = parseObj(res.body)\n if (Core:type(data) != \"obj\") {\n errorMsg = \"OSV の応答を読めませんでした\"\n return null\n }\n // 0 件のときは {} が返る (vulns キーごと無い)\n get(data, \"vulns\", [])\n}\n\n@configFolder() {\n Ui:C:folder({\n title: \"⚙️ 調べるパッケージ\"\n opened: false\n children: [\n Ui:C:select({\n items: ECOSYSTEMS\n default: eco\n onChange: @(v) {\n eco = v\n Mk:save(ECO_KEY, v)\n }\n })\n Ui:C:textInput({\n default: input\n label: \"パッケージ名\"\n text: \"misskey-js / @misskey-dev/note など\"\n onInput: @(v) { input = v }\n })\n Ui:C:button({\n text: \"🔍 調べる\"\n rounded: true\n primary: true\n onClick: @() {\n let p = normalizePkg(input)\n if (Core:type(p) != \"str\") {\n Mk:dialog(\"エラー\", \"パッケージ名に使えない文字が含まれています\", \"error\")\n } else {\n // 保存は取得成功後 (render 内)\n pkg = p\n input = p\n firstRun = true\n render()\n }\n }\n })\n ]\n })\n}\n\n@header() {\n Ui:C:mfm({ text: `🛡 **OSV 脆弱性** $[fg.color={COL_DIM} {eco} / {safeLabel(pkg)}]` })\n}\n\n@render() {\n if (firstRun) {\n Ui:render([\n Ui:C:container({\n align: \"center\"\n children: [Ui:C:text({ text: `{pkg} を照会中… 🛡` })]\n })\n ])\n }\n firstRun = false\n\n let vulns = fetchVulns(pkg, eco)\n if (Core:type(vulns) != \"arr\") {\n Ui:render([\n Ui:C:container({\n align: \"center\"\n children: [\n header()\n Ui:C:mfm({ text: `$[fg.color=ea3943 ⚠️ {dflt(errorMsg, \"取得に失敗しました\")}]` })\n Ui:C:button({ text: \"🔄 再試行\", rounded: true, onClick: @() { render() } })\n configFolder()\n ]\n })\n ])\n return null\n }\n Mk:save(PKG_KEY, pkg)\n\n var body = [header()]\n if (vulns.len == 0) {\n body.push(Ui:C:mfm({ text: `$[fg.color={COL_OK} ✅ 既知の脆弱性はありません]` }))\n } else {\n each (let r, buildRows(vulns, MAX_ITEMS)) {\n body.push(Ui:C:mfm({ text: r }))\n }\n if (vulns.len > MAX_ITEMS) {\n body.push(Ui:C:mfm({ text: `$[fg.color={COL_FLAT} 全 {vulns.len} 件のうち新しい {MAX_ITEMS} 件]` }))\n }\n }\n body.push(Ui:C:mfm({ text: `$[fg.color={COL_FLAT} データ: osv.dev]` }))\n body.push(Ui:C:button({ text: \"🔄 更新\", rounded: true, onClick: @() { render() } }))\n body.push(configFolder())\n Ui:render([\n Ui:C:container({\n align: \"center\"\n children: body\n })\n ])\n}\n\nrender()\n// アドバイザリの発行は日次より粗いので 6 時間おきで十分\nAsync:interval(21600000, render, false)\n"}
+{"type":"widget","data":"/// @ 1.2.1\n/// OSV 脆弱性ウィジェット\n/// OSV.dev (Google の Open Source Vulnerabilities) に**パッケージ名**を投げて、\n/// そのパッケージの既知脆弱性を新しい順に表示する。API キー不要。\n/// 既定は Misskey の公式 JS SDK (npm:misskey-js)。\n///\n/// cve ウィジェットが「NVD の直近 N 日の CVE を眺める」= CVE 番号起点なのに対し、\n/// こちらは「自分が使っているパッケージに何が出ているか」= パッケージ起点で引く。\n///\n/// 実装メモ:\n/// - POST https://api.osv.dev/v1/query に {\"package\":{\"name\",\"ecosystem\"}} を送る。\n/// 本文は Json:stringify で組む (パッケージ名を文字列連結すると本文を捏造できる)。\n/// 加えて validPkg で許可文字を制限し、リンク生成にも安全な値だけ通す\n/// - ecosystem の綴りは OSV 側で厳密 (\"PyPI\" / \"crates.io\" / \"RubyGems\")。select で固定する\n/// - 0 件のときの応答は {} (vulns キーごと無い)\n/// - 大きなパッケージほど応答が重い (npm:next で約 160KB)。表示は MAX_ITEMS 件だけ\n/// - OSV の返す順は発行日順ではないので published から数値キーを作って並べ替える\n\nlet API_URL = \"https://api.osv.dev/v1/query\"\nlet PKG_KEY = \"osv-vulns:pkg\"\nlet ECO_KEY = \"osv-vulns:eco\"\nlet DEFAULT_PKG = \"misskey-js\"\nlet DEFAULT_ECO = \"npm\"\nlet MAX_ITEMS = 5 // 表示する最大件数\nlet DESC_LEN = 58 // 概要の最大表示文字数\nlet MAX_NAME = 128 // パッケージ名の最大長\n// 応答は件数に比例して重い (npm:next で約 160KB、PyPI:Django は 320 件で約 870KB)。\n// 桁違いのものはパースせず捨てる\nlet MAX_BODY = 1200000\n// パッケージ名に許す文字。\" と \\ を除くことで POST 本文を捏造されないようにする\nlet PKG_CHARS = \"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.-_/@:+\"\n\n// cve ウィジェットと揃えたカラー\nlet COL_DIM = \"5b6470\"\nlet COL_FLAT = \"8a919e\"\nlet COL_DESC = \"b0b7c3\"\nlet COL_OK = \"16c784\"\n\n// ─────────────────────────────\n// ヘルパー\n// ─────────────────────────────\n@dflt(v, d) { if (Core:type(v) == \"null\") d else v }\n\n@get(o, k, d) {\n if (Core:type(o) != \"obj\") { d }\n elif (Obj:has(o, k)) { dflt(o[k], d) }\n else { d }\n}\n\n/// レスポンス本文を JSON オブジェクトとして読む。\n/// Json:parse は失敗時に throw せず error 値を返すので、obj 以外はすべて null に潰す\n@parseObj(body) {\n if (Core:type(body) != \"str\") { return null }\n if (body.len > MAX_BODY) { return null }\n let v = Json:parse(body)\n if (Core:type(v) == \"obj\") v else null\n}\n\n/// OSV 由来の文字列を表示用に無害化する。\n/// MFM ($[...])・リンク ([...])・装飾 (*)・HTML (<>) を全角に置換して発火させない\n@safeLabel(s) {\n if (Core:type(s) != \"str\") { \"?\" }\n else {\n s.replace(\"$\", \"$\").replace(\"[\", \"[\").replace(\"]\", \"]\").replace(\"*\", \"*\").replace(\"<\", \"<\").replace(\">\", \">\")\n }\n}\n\n@trunc(s, n) {\n if (s.len > n) `{s.slice(0, n)}…` else s\n}\n\n/// パッケージ名として妥当か (許可文字のみ・長さ制限)\n@validPkg(s) {\n if (Core:type(s) != \"str\") { return false }\n if (s == \"\" || s.len > MAX_NAME) { return false }\n var ok = true\n each (let c, s.to_char_arr()) {\n if (!PKG_CHARS.incl(c)) { ok = false }\n }\n ok\n}\n\n/// 入力欄の値をパッケージ名に整える。妥当でなければ null\n@normalizePkg(s) {\n if (Core:type(s) != \"str\") { return null }\n let p = s.trim()\n if (!validPkg(p)) { return null }\n p\n}\n\n/// GHSA の重大度 (LOW / MODERATE / HIGH / CRITICAL)。無ければ \"不明\"\n@sevOf(v) {\n let s = get(get(v, \"database_specific\", null), \"severity\", null)\n if (Core:type(s) != \"str\" || s == \"\") \"不明\" else s.upper()\n}\n\n@sevColor(sev) {\n if (sev == \"CRITICAL\") { \"ea3943\" }\n elif (sev == \"HIGH\") { \"ff8c42\" }\n elif (sev == \"MODERATE\" || sev == \"MEDIUM\") { \"f5a623\" }\n elif (sev == \"LOW\") { COL_OK }\n else { COL_FLAT }\n}\n\n/// ISO 8601 から \"MM/DD\" を取り出す\n@monthDay(s) {\n if (Core:type(s) != \"str\" || s.len < 10) \"\" else `{s.slice(5, 7)}/{s.slice(8, 10)}`\n}\n\n/// published から並べ替え用の数値キー (20251215205527)。読めなければ 0。\n/// 同じ日に複数出ることがある (npm:next は 1 日に何件も) ので秒まで見る。\n/// 桁の切り出しに 1 文字ずつのループを使うと 300 件規模で数秒かかるので to_num に任せる\n@pubKey(v) {\n let s = get(v, \"published\", null)\n if (Core:type(s) != \"str\" || s.len < 19) { return 0 }\n let digits = `{s.slice(0, 4)}{s.slice(5, 7)}{s.slice(8, 10)}{s.slice(11, 13)}{s.slice(14, 16)}{s.slice(17, 19)}`\n dflt(digits.to_num(), 0)\n}\n\n/// affected[].ranges[].events[] から最初の fixed バージョンを拾う。無ければ \"\"\n@fixedVersion(v) {\n var found = \"\"\n each (let a, get(v, \"affected\", [])) {\n each (let r, get(a, \"ranges\", [])) {\n each (let e, get(r, \"events\", [])) {\n let f = get(e, \"fixed\", null)\n if (found == \"\" && Core:type(f) == \"str\") { found = f }\n }\n }\n }\n found\n}\n\n/// 脆弱性 1 件 = 2 行カード\n/// 行1: ● GHSA-ID (osv.dev へのリンク) 重大度\n/// 行2: MM/DD 概要 → 修正バージョン\n@vulnRows(v) {\n let id = safeLabel(trunc(dflt(get(v, \"id\", null), \"?\"), 40))\n let sev = sevOf(v)\n let col = sevColor(sev)\n let summary = trunc(safeLabel(dflt(get(v, \"summary\", null), \"(概要なし)\")), DESC_LEN)\n var l2 = ` $[fg.color={COL_DESC} {summary}]`\n let md = monthDay(get(v, \"published\", null))\n if (md != \"\") {\n l2 = ` $[fg.color={COL_DIM} {md}] $[fg.color={COL_DESC} {summary}]`\n }\n let fixed = fixedVersion(v)\n if (fixed != \"\") {\n l2 = `{l2} $[fg.color={COL_DIM} → {safeLabel(trunc(fixed, 24))} で修正]`\n }\n [\n `$[fg.color={col} ●] [{id}](https://osv.dev/vulnerability/{id}) $[fg.color={col} **{sev}**]`\n l2\n ]\n}\n\n/// vulns を新しい順に並べ、上位 max 件ぶんの表示行を返す。\n/// 並べ替えキーは先に 1 件 1 回だけ作る。比較関数の中で pubKey を呼ぶと\n/// 件数の多いパッケージ (PyPI:Django で 320 件) で桁違いに遅くなる\n@buildRows(vulns, max) {\n if (Core:type(vulns) != \"arr\") { return [] }\n var keyed = []\n each (let v, vulns) {\n keyed.push({ k: pubKey(v), v: v })\n }\n let sorted = keyed.sort(@(a, b) { b.k - a.k })\n var rows = []\n var shown = 0\n each (let e, sorted) {\n if (shown < max) {\n rows = rows.concat(vulnRows(e.v))\n shown += 1\n }\n }\n rows\n}\n\n// ---- ここから UI 層 (ハーネス検証はこの行より上の純粋関数のみ) ----\n\n// OSV の ecosystem 名は綴りが厳密なので select で固定する\nlet ECOSYSTEMS = [\n { text: \"npm\", value: \"npm\" }\n { text: \"PyPI\", value: \"PyPI\" }\n { text: \"crates.io\", value: \"crates.io\" }\n { text: \"Go\", value: \"Go\" }\n { text: \"RubyGems\", value: \"RubyGems\" }\n { text: \"Packagist\", value: \"Packagist\" }\n { text: \"Maven\", value: \"Maven\" }\n { text: \"NuGet\", value: \"NuGet\" }\n]\n\n// 保存値も信用せず normalizePkg に通す\nvar pkg = dflt(normalizePkg(Mk:load(PKG_KEY)), DEFAULT_PKG)\nvar eco = dflt(Mk:load(ECO_KEY), DEFAULT_ECO)\nvar input = pkg\nvar firstRun = true\nvar errorMsg = null\n\n/// OSV に問い合わせる。失敗時は null を返し errorMsg に理由を入れる\n@fetchVulns(name, ecosystem) {\n errorMsg = null\n let res = Nd:http(API_URL, {\n method: \"POST\"\n headers: {\n \"Content-Type\": \"application/json\"\n \"Accept\": \"application/json\"\n }\n body: Json:stringify({\n package: {\n name: name\n ecosystem: ecosystem\n }\n })\n })\n if (res.status != 200) {\n // status 0 は通信断 (NoteDeck は throw せず値で返す、notedeck#1294)\n errorMsg = if (res.status == 0) { \"OSV に接続できません\" } else { `OSV に問い合わせできません (HTTP {res.status})` }\n return null\n }\n let data = parseObj(res.body)\n if (Core:type(data) != \"obj\") {\n errorMsg = \"OSV の応答を読めませんでした\"\n return null\n }\n // 0 件のときは {} が返る (vulns キーごと無い)\n get(data, \"vulns\", [])\n}\n\n@configFolder() {\n Ui:C:folder({\n title: \"⚙️ 調べるパッケージ\"\n opened: false\n children: [\n Ui:C:select({\n items: ECOSYSTEMS\n default: eco\n onChange: @(v) {\n eco = v\n Mk:save(ECO_KEY, v)\n }\n })\n Ui:C:textInput({\n default: input\n label: \"パッケージ名\"\n text: \"misskey-js / @misskey-dev/note など\"\n onInput: @(v) { input = v }\n })\n Ui:C:button({\n text: \"🔍 調べる\"\n rounded: true\n primary: true\n onClick: @() {\n let p = normalizePkg(input)\n if (Core:type(p) != \"str\") {\n Mk:dialog(\"エラー\", \"パッケージ名に使えない文字が含まれています\", \"error\")\n } else {\n // 保存は取得成功後 (render 内)\n pkg = p\n input = p\n firstRun = true\n render()\n }\n }\n })\n ]\n })\n}\n\n@header() {\n Ui:C:mfm({ text: `🛡 **OSV 脆弱性** $[fg.color={COL_DIM} {eco} / {safeLabel(pkg)}]` })\n}\n\n@render() {\n if (firstRun) {\n Ui:render([\n Ui:C:container({\n align: \"center\"\n children: [Ui:C:text({ text: `{pkg} を照会中… 🛡` })]\n })\n ])\n }\n firstRun = false\n\n let vulns = fetchVulns(pkg, eco)\n if (Core:type(vulns) != \"arr\") {\n Ui:render([\n Ui:C:container({\n align: \"center\"\n children: [\n header()\n Ui:C:mfm({ text: `$[fg.color=ea3943 ⚠️ {dflt(errorMsg, \"取得に失敗しました\")}]` })\n Ui:C:button({ text: \"🔄 再試行\", rounded: true, onClick: @() { render() } })\n configFolder()\n ]\n })\n ])\n return null\n }\n Mk:save(PKG_KEY, pkg)\n\n var body = [header()]\n if (vulns.len == 0) {\n body.push(Ui:C:mfm({ text: `$[fg.color={COL_OK} ✅ 既知の脆弱性はありません]` }))\n } else {\n each (let r, buildRows(vulns, MAX_ITEMS)) {\n body.push(Ui:C:mfm({ text: r }))\n }\n if (vulns.len > MAX_ITEMS) {\n body.push(Ui:C:mfm({ text: `$[fg.color={COL_FLAT} 全 {vulns.len} 件のうち新しい {MAX_ITEMS} 件]` }))\n }\n }\n body.push(Ui:C:mfm({ text: `$[fg.color={COL_FLAT} データ: osv.dev]` }))\n body.push(Ui:C:button({ text: \"🔄 更新\", rounded: true, onClick: @() { render() } }))\n body.push(configFolder())\n Ui:render([\n Ui:C:container({\n align: \"center\"\n children: body\n })\n ])\n}\n\nrender()\n// アドバイザリの発行は日次より粗いので 6 時間おきで十分\nAsync:interval(21600000, render, false)\n"}
diff --git a/public/registry/widgets/osv-vulns/widget.is b/public/registry/widgets/osv-vulns/widget.is
index dc08007..662b189 100644
--- a/public/registry/widgets/osv-vulns/widget.is
+++ b/public/registry/widgets/osv-vulns/widget.is
@@ -213,7 +213,8 @@ var errorMsg = null
})
})
if (res.status != 200) {
- errorMsg = `OSV に問い合わせできません (HTTP {res.status})`
+ // status 0 は通信断 (NoteDeck は throw せず値で返す、notedeck#1294)
+ errorMsg = if (res.status == 0) { "OSV に接続できません" } else { `OSV に問い合わせできません (HTTP {res.status})` }
return null
}
let data = parseObj(res.body)
diff --git a/public/registry/widgets/soundcloud-feed/api.json b/public/registry/widgets/soundcloud-feed/api.json
index c910e12..27ae112 100644
--- a/public/registry/widgets/soundcloud-feed/api.json
+++ b/public/registry/widgets/soundcloud-feed/api.json
@@ -1 +1 @@
-{"type":"widget","data":"/// @ 1.2.1\n/// SoundCloud 新着トラック ウィジェット\n/// 指定したユーザーの SoundCloud RSS フィード (feeds.soundcloud.com) から最新トラックを表示する。\n/// API キー・ログイン不要。再生中の共有は listenbrainz ウィジェットの #nowplaying に任せ、\n/// ここは一覧だけ (各行のリンクから SoundCloud を開く)。\n///\n/// 設定にはプロフィール URL / ユーザー名 / 数値 ID のどれを入れてもよい。\n/// ユーザー名は SoundCloud の oEmbed (認証不要) で数値 ID に解決して Mk:save にキャッシュし、\n/// RSS は Misskey サーバーの fetch-rss (rss-reader ウィジェットと同じ) で取るので XML は自前で読まない。\n///\n/// 注意: SoundCloud の RSS は作者が「RSS 配信 (ポッドキャスト)」を有効にしたトラックだけを含む。\n/// 音楽系アカウントの多くは空 (0 件) で、その場合は案内を出す。\n/// 設計の元ネタ: rss-reader (fetch-rss) / osu-stats (設定 UI)。\n\nlet OEMBED = \"https://soundcloud.com/oembed?format=json&url=\"\nlet UA = \"misstore-soundcloud-feed/1.0 (https://github.com/notedeck-dev/misstore)\"\nlet COUNT = 8\nlet REFRESH_MS = 1800000\n\n// カラーパレット\nlet MUTED = \"8a96a3\"\nlet LINE = \"5a6573\"\nlet ACCENT = \"ff5500\" // SoundCloud オレンジ\nlet RED = \"ff6961\"\n\n// ─────────────────────────────\n// ヘルパー\n// ─────────────────────────────\n@dflt(v, d) { if (Core:type(v) == \"null\") d else v }\n\n@get(o, k, d) {\n if (Core:type(o) != \"obj\") { d }\n elif (Obj:has(o, k)) { dflt(o[k], d) }\n else { d }\n}\n\n@num(v) {\n if (Core:type(v) == \"num\") { return v }\n if (Core:type(v) != \"str\") { return 0 }\n let n = v.to_num()\n if (Core:type(n) == \"num\") { n } else { 0 }\n}\n\n@trim(s) {\n if (Core:type(s) != \"str\") { return \"\" }\n var a = 0\n var b = s.len\n loop { if (a < b && s.slice(a, a + 1) == \" \") { a = a + 1 } else { break } }\n loop { if (b > a && s.slice(b - 1, b) == \" \") { b = b - 1 } else { break } }\n s.slice(a, b)\n}\n\n@isAllDigits(s) {\n if (s.len == 0) { return false }\n var ok = true\n for (let i, s.len) {\n if (Core:type(s.slice(i, i + 1).to_num()) != \"num\") { ok = false }\n }\n ok\n}\n\n/// MFM 制御文字を無害化。曲名・作者名は外部由来なので必ず通す\n@sanitizeText(s) {\n if (Core:type(s) != \"str\") { return \"(不明)\" }\n var txt = s.replace(\"$[\", \"$ \")\n txt = txt.replace(\"]\", \"]\")\n txt = txt.replace(\"[\", \"[\")\n txt = txt.replace(\"(\", \"(\")\n txt = txt.replace(\")\", \")\")\n txt = txt.replace(\"<\", \"<\")\n txt = txt.replace(\">\", \">\")\n txt = txt.replace(\"`\", \"`\")\n txt = txt.replace(\"*\", \"*\")\n txt = txt.replace(\"~\", \"〜\")\n txt = txt.replace(\"\\\\\", \"\\")\n txt = txt.replace(\"@\", \"@\")\n txt = txt.replace(\"#\", \"#\")\n txt = txt.replace(\"://\", \"://\")\n txt\n}\n\n@truncName(s, n) {\n if (s.len > n) { `{s.slice(0, n)}…` } else { s }\n}\n\n@pad2(n) { if (n < 10) { `0{n}` } else { `{n}` } }\n\n/// itunes:duration は \"244\" (秒) か \"4:04\" / \"1:02:03\"。秒なら m:ss に直す\n@fmtDuration(d) {\n if (Core:type(d) != \"str\" || d == \"\") { return \"\" }\n if (d.incl(\":\")) { return d }\n let total = num(d)\n if (total <= 0) { return \"\" }\n let h = Math:floor(total / 3600)\n let m = Math:floor((total % 3600) / 60)\n let s = total % 60\n if (h > 0) { `{h}:{pad2(m)}:{pad2(s)}` } else { `{m}:{pad2(s)}` }\n}\n\n@ago(ms) {\n let diff = Date:now() - ms\n if (diff < 3600000) { `{Math:max(1, Math:floor(diff / 60000))} 分前` }\n elif (diff < 86400000) { `{Math:floor(diff / 3600000)} 時間前` }\n elif (diff < 2592000000) { `{Math:floor(diff / 86400000)} 日前` }\n elif (diff < 31536000000) { `{Math:floor(diff / 2592000000)} か月前` }\n else { `{Math:floor(diff / 31536000000)} 年前` }\n}\n\n@divider() {\n Ui:C:mfm({ text: `$[fg.color={LINE} ───────────────]` })\n}\n\n// ─────────────────────────────\n// 状態\n// ─────────────────────────────\nvar userInput = trim(dflt(Mk:load(\"scUser\"), \"\"))\nvar resolved = Mk:load(\"scResolved\") // { user, id, name } | null\nvar result = null // null = 未取得 / { error } / { author, tracks }\nvar firstRun = true\n\n/// 入力 (URL / ユーザー名 / 数値 ID) → ユーザー名か数値 ID\n@normalizeUser(s) {\n var u = trim(s)\n if (u.starts_with(\"https://\")) { u = u.slice(8, u.len) }\n elif (u.starts_with(\"http://\")) { u = u.slice(7, u.len) }\n if (u.starts_with(\"soundcloud.com/\")) { u = u.slice(15, u.len) }\n if (u.starts_with(\"@\")) { u = u.slice(1, u.len) }\n // パスの最初の区切りまで (末尾 / や /tracks を落とす)\n let parts = u.split(\"/\")\n if (parts.len > 0) { u = parts[0] }\n let q = u.split(\"?\")\n if (q.len > 0) { u = q[0] }\n u\n}\n\n// ─────────────────────────────\n// データ取得\n// ─────────────────────────────\n/// oEmbed の html にある api.soundcloud.com%2Fusers%2F から数値 ID を拾う\n@idFromOembedHtml(html) {\n var idx = html.index_of(\"users%2F\")\n var skip = 8\n if (idx < 0) { idx = html.index_of(\"users/\"); skip = 6 }\n if (idx < 0) { return \"\" }\n var i = idx + skip\n var id = \"\"\n loop {\n if (i >= html.len) { break }\n let c = html.slice(i, i + 1)\n if (Core:type(c.to_num()) != \"num\") { break }\n id = `{id}{c}`\n i = i + 1\n }\n id\n}\n\n/// ユーザー名 → { id, name } | { error }\n@resolveUser(name) {\n let res = Nd:http(`{OEMBED}{Uri:encode_component(`https://soundcloud.com/{name}`)}`, {\n method: \"GET\"\n headers: { \"Accept\": \"application/json\", \"User-Agent\": UA }\n })\n if (res.status == 404) { return { error: `SoundCloud ユーザー「{name}」が見つかりません` } }\n if (res.status != 200) { return { error: `oEmbed エラー (HTTP {res.status})` } }\n let data = Json:parse(res.body)\n let id = idFromOembedHtml(get(data, \"html\", \"\"))\n if (id == \"\") { return { error: \"ユーザー ID を解決できません (数値 ID を直接指定してください)\" } }\n { id: id, name: get(data, \"author_name\", get(data, \"title\", name)) }\n}\n\n@toTrack(it) {\n let iso = get(it, \"isoDate\", \"\")\n var ms = Date:parse(if (iso != \"\") { iso } else { get(it, \"pubDate\", \"\") })\n if (Core:type(ms) != \"num\") { ms = 0 }\n {\n title: sanitizeText(get(it, \"title\", \"\"))\n link: get(it, \"link\", \"\")\n at: ms\n duration: fmtDuration(get(get(it, \"itunes\", {}), \"duration\", \"\"))\n }\n}\n\n@fetchData() {\n let key = normalizeUser(userInput)\n if (key == \"\") { return null }\n\n // 1. 数値 ID の解決 (数字だけならそのまま、キャッシュがあれば使う)\n var id = \"\"\n var author = \"\"\n if (isAllDigits(key)) {\n id = key\n } elif (Core:type(resolved) == \"obj\" && get(resolved, \"user\", \"\") == key) {\n id = get(resolved, \"id\", \"\")\n author = get(resolved, \"name\", \"\")\n } else {\n let r = resolveUser(key)\n if (Obj:has(r, \"error\")) {\n result = { error: r.error }\n render()\n return null\n }\n id = r.id\n author = r.name\n resolved = { user: key, id: id, name: author }\n Mk:save(\"scResolved\", resolved)\n }\n\n // 2. RSS (Misskey サーバー側で XML をパース)\n let feed = Mk:api(\"fetch-rss\", { url: `https://feeds.soundcloud.com/users/soundcloud:users:{id}/sounds.rss` })\n if (author == \"\") { author = get(feed, \"title\", `users:{id}`) }\n var tracks = []\n each (let it, get(feed, \"items\", [])) { tracks.push(toTrack(it)) }\n tracks = tracks.sort(@(a, b) { b.at - a.at }).slice(0, COUNT)\n result = { author: sanitizeText(author), tracks: tracks }\n render()\n}\n\n// ─────────────────────────────\n// UI\n// ─────────────────────────────\n@configFolder(opened) {\n Ui:C:folder({\n title: \"⚙️ 設定\"\n opened: opened\n children: [\n Ui:C:textInput({\n default: userInput\n label: \"SoundCloud ユーザー\"\n caption: \"プロフィール URL / ユーザー名 / 数値 ID。変更後は「適用」を押す\"\n onInput: @(v) { userInput = trim(v); Mk:save(\"scUser\", userInput) }\n })\n Ui:C:button({ text: \"適用して再読み込み\", onClick: @() { fetchData() } })\n ]\n })\n}\n\nlet NEW_MS = 604800000 // 7 日以内は NEW\n\n@trackMeta(tr) {\n var parts = []\n if (tr.duration != \"\") { parts.push(`⏱ {tr.duration}`) }\n if (tr.at > 0) { parts.push(ago(tr.at)) }\n parts.join(\" · \")\n}\n\n@newChip(tr) {\n if (tr.at > 0 && Date:now() - tr.at < NEW_MS) { ` $[border.radius=4 $[bg.color={ACCENT} $[fg.color=ffffff ** NEW **]]]` } else { \"\" }\n}\n\n@trackRow(tr) {\n let l1 = `🎵 [{truncName(tr.title, 40)}]({tr.link}){newChip(tr)}`\n let l2 = `$[fg.color={MUTED} {trackMeta(tr)}]`\n Ui:C:mfm({ text: `{l1}{Str:lf}{l2}` })\n}\n\n@feedView(res) {\n var body = [\n Ui:C:mfm({ text: `$[fg.color={MUTED} ☁️ SoundCloud]` })\n Ui:C:mfm({ text: `$[x2 $[fg.color={ACCENT} **{truncName(res.author, 20)}**]]` })\n ]\n if (res.tracks.len == 0) {\n body.push(Ui:C:mfm({ text: `$[x2 📭]` }))\n body.push(Ui:C:mfm({ text: `$[fg.color={MUTED} RSS にトラックがありません]` }))\n body.push(Ui:C:mfm({ text: `$[fg.color={MUTED} SoundCloud の RSS は作者が設定で RSS 配信 (ポッドキャスト) を有効にしたトラックだけを含みます。音楽系アカウントは空のことが多いです]` }))\n return body\n }\n let latest = res.tracks[0]\n let latestTxt = if (latest.at > 0) { `最新 {ago(latest.at)}` } else { \"\" }\n body.push(Ui:C:mfm({ text: `$[fg.color={MUTED} 新着 {res.tracks.len} 件 · {latestTxt}]` }))\n body.push(divider())\n var rows = []\n each (let tr, res.tracks) { rows.push(trackRow(tr)) }\n // 全体は中央寄せだが、一覧は左寄せの方が読みやすい\n body.push(Ui:C:container({ align: \"left\", children: rows }))\n body\n}\n\n@render() {\n if (normalizeUser(userInput) == \"\") {\n Ui:render([\n Ui:C:container({\n align: \"center\"\n padding: 10\n children: [\n Ui:C:mfm({ text: `$[fg.color={MUTED} ☁️ SoundCloud のユーザーを設定してください]` })\n configFolder(true)\n ]\n })\n ])\n return null\n }\n if (firstRun && Core:type(result) == \"null\") {\n Ui:render([\n Ui:C:container({ align: \"center\", padding: 10, children: [ Ui:C:text({ text: \"SoundCloud の新着を読み込み中… ☁️\" }) ] })\n ])\n firstRun = false\n fetchData()\n return null\n }\n var children = []\n if (Core:type(result) == \"null\") {\n children = [ Ui:C:container({ align: \"center\", children: [\n Ui:C:button({ text: \"🔄 更新\", primary: true, rounded: true, onClick: @() { fetchData() } })\n ] }) ]\n } elif (Obj:has(result, \"error\")) {\n children = [\n Ui:C:mfm({ text: `$[fg.color={RED} ⚠️ {result.error}]` })\n configFolder(true)\n Ui:C:container({ align: \"center\", children: [\n Ui:C:button({ text: \"🔄 再試行\", primary: true, rounded: true, onClick: @() { fetchData() } })\n ] })\n ]\n } else {\n children = feedView(result)\n children.push(divider())\n children.push(configFolder(false))\n children.push(Ui:C:container({ align: \"center\", children: [\n Ui:C:button({ text: \"🔄 更新\", rounded: true, onClick: @() { fetchData() } })\n ] }))\n children.push(Ui:C:mfm({ text: `$[fg.color={MUTED} SoundCloud RSS · 30 分ごとに更新]` }))\n }\n Ui:render([ Ui:C:container({ align: \"center\", padding: 10, children: children }) ])\n}\n\nrender()\nAsync:interval(REFRESH_MS, fetchData, false)\n"}
+{"type":"widget","data":"/// @ 1.2.1\n/// SoundCloud 新着トラック ウィジェット\n/// 指定したユーザーの SoundCloud RSS フィード (feeds.soundcloud.com) から最新トラックを表示する。\n/// API キー・ログイン不要。再生中の共有は listenbrainz ウィジェットの #nowplaying に任せ、\n/// ここは一覧だけ (各行のリンクから SoundCloud を開く)。\n///\n/// 設定にはプロフィール URL / ユーザー名 / 数値 ID のどれを入れてもよい。\n/// ユーザー名は SoundCloud の oEmbed (認証不要) で数値 ID に解決して Mk:save にキャッシュし、\n/// RSS は Misskey サーバーの fetch-rss (rss-reader ウィジェットと同じ) で取るので XML は自前で読まない。\n///\n/// 注意: SoundCloud の RSS は作者が「RSS 配信 (ポッドキャスト)」を有効にしたトラックだけを含む。\n/// 音楽系アカウントの多くは空 (0 件) で、その場合は案内を出す。\n/// 設計の元ネタ: rss-reader (fetch-rss) / osu-stats (設定 UI)。\n\nlet OEMBED = \"https://soundcloud.com/oembed?format=json&url=\"\nlet UA = \"misstore-soundcloud-feed/1.0 (https://github.com/notedeck-dev/misstore)\"\nlet COUNT = 8\nlet REFRESH_MS = 1800000\n\n// カラーパレット\nlet MUTED = \"8a96a3\"\nlet LINE = \"5a6573\"\nlet ACCENT = \"ff5500\" // SoundCloud オレンジ\nlet RED = \"ff6961\"\n\n// ─────────────────────────────\n// ヘルパー\n// ─────────────────────────────\n@dflt(v, d) { if (Core:type(v) == \"null\") d else v }\n\n@get(o, k, d) {\n if (Core:type(o) != \"obj\") { d }\n elif (Obj:has(o, k)) { dflt(o[k], d) }\n else { d }\n}\n\n@num(v) {\n if (Core:type(v) == \"num\") { return v }\n if (Core:type(v) != \"str\") { return 0 }\n let n = v.to_num()\n if (Core:type(n) == \"num\") { n } else { 0 }\n}\n\n@trim(s) {\n if (Core:type(s) != \"str\") { return \"\" }\n var a = 0\n var b = s.len\n loop { if (a < b && s.slice(a, a + 1) == \" \") { a = a + 1 } else { break } }\n loop { if (b > a && s.slice(b - 1, b) == \" \") { b = b - 1 } else { break } }\n s.slice(a, b)\n}\n\n@isAllDigits(s) {\n if (s.len == 0) { return false }\n var ok = true\n for (let i, s.len) {\n if (Core:type(s.slice(i, i + 1).to_num()) != \"num\") { ok = false }\n }\n ok\n}\n\n/// MFM 制御文字を無害化。曲名・作者名は外部由来なので必ず通す\n@sanitizeText(s) {\n if (Core:type(s) != \"str\") { return \"(不明)\" }\n var txt = s.replace(\"$[\", \"$ \")\n txt = txt.replace(\"]\", \"]\")\n txt = txt.replace(\"[\", \"[\")\n txt = txt.replace(\"(\", \"(\")\n txt = txt.replace(\")\", \")\")\n txt = txt.replace(\"<\", \"<\")\n txt = txt.replace(\">\", \">\")\n txt = txt.replace(\"`\", \"`\")\n txt = txt.replace(\"*\", \"*\")\n txt = txt.replace(\"~\", \"〜\")\n txt = txt.replace(\"\\\\\", \"\\")\n txt = txt.replace(\"@\", \"@\")\n txt = txt.replace(\"#\", \"#\")\n txt = txt.replace(\"://\", \"://\")\n txt\n}\n\n@truncName(s, n) {\n if (s.len > n) { `{s.slice(0, n)}…` } else { s }\n}\n\n@pad2(n) { if (n < 10) { `0{n}` } else { `{n}` } }\n\n/// itunes:duration は \"244\" (秒) か \"4:04\" / \"1:02:03\"。秒なら m:ss に直す\n@fmtDuration(d) {\n if (Core:type(d) != \"str\" || d == \"\") { return \"\" }\n if (d.incl(\":\")) { return d }\n let total = num(d)\n if (total <= 0) { return \"\" }\n let h = Math:floor(total / 3600)\n let m = Math:floor((total % 3600) / 60)\n let s = total % 60\n if (h > 0) { `{h}:{pad2(m)}:{pad2(s)}` } else { `{m}:{pad2(s)}` }\n}\n\n@ago(ms) {\n let diff = Date:now() - ms\n if (diff < 3600000) { `{Math:max(1, Math:floor(diff / 60000))} 分前` }\n elif (diff < 86400000) { `{Math:floor(diff / 3600000)} 時間前` }\n elif (diff < 2592000000) { `{Math:floor(diff / 86400000)} 日前` }\n elif (diff < 31536000000) { `{Math:floor(diff / 2592000000)} か月前` }\n else { `{Math:floor(diff / 31536000000)} 年前` }\n}\n\n@divider() {\n Ui:C:mfm({ text: `$[fg.color={LINE} ───────────────]` })\n}\n\n// ─────────────────────────────\n// 状態\n// ─────────────────────────────\nvar userInput = trim(dflt(Mk:load(\"scUser\"), \"\"))\nvar resolved = Mk:load(\"scResolved\") // { user, id, name } | null\nvar result = null // null = 未取得 / { error } / { author, tracks }\nvar firstRun = true\n\n/// 入力 (URL / ユーザー名 / 数値 ID) → ユーザー名か数値 ID\n@normalizeUser(s) {\n var u = trim(s)\n if (u.starts_with(\"https://\")) { u = u.slice(8, u.len) }\n elif (u.starts_with(\"http://\")) { u = u.slice(7, u.len) }\n if (u.starts_with(\"soundcloud.com/\")) { u = u.slice(15, u.len) }\n if (u.starts_with(\"@\")) { u = u.slice(1, u.len) }\n // パスの最初の区切りまで (末尾 / や /tracks を落とす)\n let parts = u.split(\"/\")\n if (parts.len > 0) { u = parts[0] }\n let q = u.split(\"?\")\n if (q.len > 0) { u = q[0] }\n u\n}\n\n// ─────────────────────────────\n// データ取得\n// ─────────────────────────────\n/// oEmbed の html にある api.soundcloud.com%2Fusers%2F から数値 ID を拾う\n@idFromOembedHtml(html) {\n var idx = html.index_of(\"users%2F\")\n var skip = 8\n if (idx < 0) { idx = html.index_of(\"users/\"); skip = 6 }\n if (idx < 0) { return \"\" }\n var i = idx + skip\n var id = \"\"\n loop {\n if (i >= html.len) { break }\n let c = html.slice(i, i + 1)\n if (Core:type(c.to_num()) != \"num\") { break }\n id = `{id}{c}`\n i = i + 1\n }\n id\n}\n\n/// ユーザー名 → { id, name } | { error }\n@resolveUser(name) {\n let res = Nd:http(`{OEMBED}{Uri:encode_component(`https://soundcloud.com/{name}`)}`, {\n method: \"GET\"\n headers: { \"Accept\": \"application/json\", \"User-Agent\": UA }\n })\n // status 0 は通信断 (NoteDeck は throw せず値で返す、notedeck#1294)\n if (res.status == 0) { return { error: \"SoundCloud に接続できません\" } }\n if (res.status == 404) { return { error: `SoundCloud ユーザー「{name}」が見つかりません` } }\n if (res.status != 200) { return { error: `oEmbed エラー (HTTP {res.status})` } }\n let data = Json:parse(res.body)\n let id = idFromOembedHtml(get(data, \"html\", \"\"))\n if (id == \"\") { return { error: \"ユーザー ID を解決できません (数値 ID を直接指定してください)\" } }\n { id: id, name: get(data, \"author_name\", get(data, \"title\", name)) }\n}\n\n@toTrack(it) {\n let iso = get(it, \"isoDate\", \"\")\n var ms = Date:parse(if (iso != \"\") { iso } else { get(it, \"pubDate\", \"\") })\n if (Core:type(ms) != \"num\") { ms = 0 }\n {\n title: sanitizeText(get(it, \"title\", \"\"))\n link: get(it, \"link\", \"\")\n at: ms\n duration: fmtDuration(get(get(it, \"itunes\", {}), \"duration\", \"\"))\n }\n}\n\n@fetchData() {\n let key = normalizeUser(userInput)\n if (key == \"\") { return null }\n\n // 1. 数値 ID の解決 (数字だけならそのまま、キャッシュがあれば使う)\n var id = \"\"\n var author = \"\"\n if (isAllDigits(key)) {\n id = key\n } elif (Core:type(resolved) == \"obj\" && get(resolved, \"user\", \"\") == key) {\n id = get(resolved, \"id\", \"\")\n author = get(resolved, \"name\", \"\")\n } else {\n let r = resolveUser(key)\n if (Obj:has(r, \"error\")) {\n result = { error: r.error }\n render()\n return null\n }\n id = r.id\n author = r.name\n resolved = { user: key, id: id, name: author }\n Mk:save(\"scResolved\", resolved)\n }\n\n // 2. RSS (Misskey サーバー側で XML をパース)\n let feed = Mk:api(\"fetch-rss\", { url: `https://feeds.soundcloud.com/users/soundcloud:users:{id}/sounds.rss` })\n if (author == \"\") { author = get(feed, \"title\", `users:{id}`) }\n var tracks = []\n each (let it, get(feed, \"items\", [])) { tracks.push(toTrack(it)) }\n tracks = tracks.sort(@(a, b) { b.at - a.at }).slice(0, COUNT)\n result = { author: sanitizeText(author), tracks: tracks }\n render()\n}\n\n// ─────────────────────────────\n// UI\n// ─────────────────────────────\n@configFolder(opened) {\n Ui:C:folder({\n title: \"⚙️ 設定\"\n opened: opened\n children: [\n Ui:C:textInput({\n default: userInput\n label: \"SoundCloud ユーザー\"\n caption: \"プロフィール URL / ユーザー名 / 数値 ID。変更後は「適用」を押す\"\n onInput: @(v) { userInput = trim(v); Mk:save(\"scUser\", userInput) }\n })\n Ui:C:button({ text: \"適用して再読み込み\", onClick: @() { fetchData() } })\n ]\n })\n}\n\nlet NEW_MS = 604800000 // 7 日以内は NEW\n\n@trackMeta(tr) {\n var parts = []\n if (tr.duration != \"\") { parts.push(`⏱ {tr.duration}`) }\n if (tr.at > 0) { parts.push(ago(tr.at)) }\n parts.join(\" · \")\n}\n\n@newChip(tr) {\n if (tr.at > 0 && Date:now() - tr.at < NEW_MS) { ` $[border.radius=4 $[bg.color={ACCENT} $[fg.color=ffffff ** NEW **]]]` } else { \"\" }\n}\n\n@trackRow(tr) {\n let l1 = `🎵 [{truncName(tr.title, 40)}]({tr.link}){newChip(tr)}`\n let l2 = `$[fg.color={MUTED} {trackMeta(tr)}]`\n Ui:C:mfm({ text: `{l1}{Str:lf}{l2}` })\n}\n\n@feedView(res) {\n var body = [\n Ui:C:mfm({ text: `$[fg.color={MUTED} ☁️ SoundCloud]` })\n Ui:C:mfm({ text: `$[x2 $[fg.color={ACCENT} **{truncName(res.author, 20)}**]]` })\n ]\n if (res.tracks.len == 0) {\n body.push(Ui:C:mfm({ text: `$[x2 📭]` }))\n body.push(Ui:C:mfm({ text: `$[fg.color={MUTED} RSS にトラックがありません]` }))\n body.push(Ui:C:mfm({ text: `$[fg.color={MUTED} SoundCloud の RSS は作者が設定で RSS 配信 (ポッドキャスト) を有効にしたトラックだけを含みます。音楽系アカウントは空のことが多いです]` }))\n return body\n }\n let latest = res.tracks[0]\n let latestTxt = if (latest.at > 0) { `最新 {ago(latest.at)}` } else { \"\" }\n body.push(Ui:C:mfm({ text: `$[fg.color={MUTED} 新着 {res.tracks.len} 件 · {latestTxt}]` }))\n body.push(divider())\n var rows = []\n each (let tr, res.tracks) { rows.push(trackRow(tr)) }\n // 全体は中央寄せだが、一覧は左寄せの方が読みやすい\n body.push(Ui:C:container({ align: \"left\", children: rows }))\n body\n}\n\n@render() {\n if (normalizeUser(userInput) == \"\") {\n Ui:render([\n Ui:C:container({\n align: \"center\"\n padding: 10\n children: [\n Ui:C:mfm({ text: `$[fg.color={MUTED} ☁️ SoundCloud のユーザーを設定してください]` })\n configFolder(true)\n ]\n })\n ])\n return null\n }\n if (firstRun && Core:type(result) == \"null\") {\n Ui:render([\n Ui:C:container({ align: \"center\", padding: 10, children: [ Ui:C:text({ text: \"SoundCloud の新着を読み込み中… ☁️\" }) ] })\n ])\n firstRun = false\n fetchData()\n return null\n }\n var children = []\n if (Core:type(result) == \"null\") {\n children = [ Ui:C:container({ align: \"center\", children: [\n Ui:C:button({ text: \"🔄 更新\", primary: true, rounded: true, onClick: @() { fetchData() } })\n ] }) ]\n } elif (Obj:has(result, \"error\")) {\n children = [\n Ui:C:mfm({ text: `$[fg.color={RED} ⚠️ {result.error}]` })\n configFolder(true)\n Ui:C:container({ align: \"center\", children: [\n Ui:C:button({ text: \"🔄 再試行\", primary: true, rounded: true, onClick: @() { fetchData() } })\n ] })\n ]\n } else {\n children = feedView(result)\n children.push(divider())\n children.push(configFolder(false))\n children.push(Ui:C:container({ align: \"center\", children: [\n Ui:C:button({ text: \"🔄 更新\", rounded: true, onClick: @() { fetchData() } })\n ] }))\n children.push(Ui:C:mfm({ text: `$[fg.color={MUTED} SoundCloud RSS · 30 分ごとに更新]` }))\n }\n Ui:render([ Ui:C:container({ align: \"center\", padding: 10, children: children }) ])\n}\n\nrender()\nAsync:interval(REFRESH_MS, fetchData, false)\n"}
diff --git a/public/registry/widgets/soundcloud-feed/widget.is b/public/registry/widgets/soundcloud-feed/widget.is
index 37aeaf8..249e0ff 100644
--- a/public/registry/widgets/soundcloud-feed/widget.is
+++ b/public/registry/widgets/soundcloud-feed/widget.is
@@ -160,6 +160,8 @@ var firstRun = true
method: "GET"
headers: { "Accept": "application/json", "User-Agent": UA }
})
+ // status 0 は通信断 (NoteDeck は throw せず値で返す、notedeck#1294)
+ if (res.status == 0) { return { error: "SoundCloud に接続できません" } }
if (res.status == 404) { return { error: `SoundCloud ユーザー「{name}」が見つかりません` } }
if (res.status != 200) { return { error: `oEmbed エラー (HTTP {res.status})` } }
let data = Json:parse(res.body)
diff --git a/public/registry/widgets/stocks/api.json b/public/registry/widgets/stocks/api.json
index 3229aa1..b4612cc 100644
--- a/public/registry/widgets/stocks/api.json
+++ b/public/registry/widgets/stocks/api.json
@@ -1 +1 @@
-{"type":"widget","data":"/// @ 1.2.1\n/// 株価ウィジェット\n/// Yahoo Finance の chart API で株価・指数・為替の現在値・前日比・5日トレンドを表示。API キー不要。\n/// v8/finance/chart エンドポイントを銘柄ごとに叩き、meta(現在値・前日終値)と\n/// 終値の時系列(スパークライン用)を取得する。\n/// 見た目は crypto ウィジェットと同じ monospace 整列レイアウト。\n/// 銘柄を変えるには下の DEFAULT_SYMBOLS を直接編集する (Yahoo Finance のティッカー)。\n/// 例: AAPL (米株) / 7203.T (日本株) / ^N225 (指数) / JPY=X (為替)\n///\n/// デフォルトは「AI・半導体・宇宙・クラウド」という今後の成長軸に寄せた厳選:\n/// ^N225 日経平均 — 国内の地合い\n/// JPY=X ドル円 — VPS/API 課金に直結する円相場\n/// NVDA NVIDIA — GPU、AI インフラの中心\n/// MSFT Microsoft — Azure / OpenAI 出資、AI クラウド\n/// GOOGL Alphabet — Gemini / 検索\n/// META Meta — Llama、ActivityPub(Threads) で Fedi とも接点\n/// SPCX SpaceX — 2026/6 上場、宇宙・Starlink\n/// (MANGOS のうち Anthropic / OpenAI は非上場のため対象外。\n/// 日本株(キオクシア 285A.T / さくらインターネット 3778.T) もティッカー指定で追加可)\n\nlet DEFAULT_SYMBOLS = \"^N225,JPY=X,NVDA,MSFT,GOOGL,META,SPCX\"\nlet RANGE = \"5d\" // スパークライン用の取得期間\nlet WIDTH = 24 // monospace 整列の桁幅 (半角換算)\n\nvar lastStatus = 0 // 直近の HTTP ステータス (エラー表示用)\nvar sawRateLimit = false // 今回の更新でいずれかの銘柄が 429 を返したか (バックオフ判定用)\n\n// crypto ウィジェットと揃えたカラー\nlet COL_UP = \"16c784\"\nlet COL_DOWN = \"ea3943\"\nlet COL_FLAT = \"8a919e\"\nlet COL_DIM = \"5b6470\"\nlet COL_NAME = \"eff2f5\"\nlet COL_PRICE = \"d0d5dd\"\n\n// ─────────────────────────────\n// ヘルパー\n// ─────────────────────────────\n@dflt(v, d) { if (Core:type(v) == \"null\") d else v }\n\n@get(o, k, d) {\n if (Core:type(o) != \"obj\") { d }\n elif (Obj:has(o, k)) { dflt(o[k], d) }\n else { d }\n}\n\n@round2(x) {\n if (Core:type(x) == \"null\") { 0 }\n else { Math:round(x * 100) / 100 }\n}\n\n@pad2(n) { if (n < 10) { `0{n}` } else { `{n}` } }\n\n/// 同じ文字を n 個並べた文字列\n@repeatStr(ch, n) {\n var s = \"\"\n if (n > 0) { for (let i, n) { s = `{s}{ch}` } }\n s\n}\n\n/// 左セグメントと右セグメントを幅 w に揃える (間をスペース埋め)\n@justify(lPlain, lShow, rPlain, rShow, w) {\n let gap = w - lPlain.len - rPlain.len\n `{lShow}{repeatStr(\" \", if (gap > 0) { gap } else { 1 })}{rShow}`\n}\n\n/// 非負整数に3桁区切りのカンマを挿入 (再帰)\n@addCommas(n) {\n if (n < 1000) { `{n}` }\n else {\n let head = addCommas(Math:floor(n / 1000))\n let tail = n % 1000\n let t = if (tail < 10) { `00{tail}` } elif (tail < 100) { `0{tail}` } else { `{tail}` }\n `{head},{t}`\n }\n}\n\n@curSymbol(cur) {\n if (cur == \"JPY\") { \"¥\" }\n elif (cur == \"USD\") { \"$\" }\n elif (cur == \"EUR\") { \"€\" }\n elif (cur == \"GBP\") { \"£\" }\n else { \"\" }\n}\n\n/// 価格を商品種別に応じて整形\n/// CURRENCY(為替): 記号なし小数 (161.57)\n/// INDEX(指数) : 記号なしカンマ区切り (38,403)\n/// それ以外(株式) : 通貨記号 + カンマ区切り\n@fmtPrice(p, cur, itype) {\n if (Core:type(p) == \"null\") { \"-\" }\n elif (itype == \"CURRENCY\") { `{round2(p)}` }\n elif (itype == \"INDEX\") { `{addCommas(Math:round(p))}` }\n else {\n let sym = curSymbol(cur)\n if (p >= 100) { `{sym}{addCommas(Math:round(p))}` }\n elif (p >= 1) { `{sym}{round2(p)}` }\n else { `{sym}{Math:round(p * 100) / 100}` }\n }\n}\n\n/// ティッカー → 識別用の絵文字 (登録のある銘柄のみ)\n@emojiFor(symbol) {\n if (symbol == \"^N225\") { \"🎌\" }\n elif (symbol == \"JPY=X\") { \"💱\" }\n elif (symbol == \"3778.T\") { \"🌸\" }\n elif (symbol == \"285A.T\") { \"💾\" }\n elif (symbol == \"NVDA\") { \"🟩\" }\n elif (symbol == \"AAPL\") { \"🍎\" }\n elif (symbol == \"MSFT\") { \"🪟\" }\n elif (symbol == \"GOOGL\") { \"🔤\" }\n elif (symbol == \"AMZN\") { \"📦\" }\n elif (symbol == \"META\") { \"📘\" }\n elif (symbol == \"SPCX\") { \"🚀\" }\n else { \"\" }\n}\n\n/// 表示名 (Yahoo の shortName を優先、長ければ切り詰め)\n@labelFor(symbol, mt) {\n let nm = get(mt, \"shortName\", symbol)\n let nmShort = if (nm.len > 11) { nm.slice(0, 11) } else { nm }\n let e = emojiFor(symbol)\n if (e != \"\") { `{e} {nmShort}` } else { nmShort }\n}\n\n@chgArrow(chg) {\n if (Core:type(chg) == \"null\") { \"■\" }\n elif (chg > 0) { \"▲\" }\n elif (chg < 0) { \"▼\" }\n else { \"■\" }\n}\n\n@chgColor(chg) {\n if (Core:type(chg) == \"null\") { COL_FLAT }\n elif (chg > 0) { COL_UP }\n elif (chg < 0) { COL_DOWN }\n else { COL_FLAT }\n}\n\n/// 数値のみを抜き出す (Yahoo の close 配列は null を含むことがある)\n@cleanNums(arr) {\n if (Core:type(arr) != \"arr\") { return [] }\n var nums = []\n for (let i, arr.len) { if (Core:type(arr[i]) == \"num\") { nums.push(arr[i]) } }\n nums\n}\n\n/// 終値配列をブロック文字のスパークラインに変換 (素テキスト)\n@sparkText(prices, points) {\n if (Core:type(prices) != \"arr\") { return \"\" }\n let plen = prices.len\n if (plen < 2) { return \"\" }\n let blocks = [\"▁\", \"▂\", \"▃\", \"▄\", \"▅\", \"▆\", \"▇\", \"█\"]\n let m = if (plen > points) { points } else { plen }\n let stride = plen / m\n var sampled = []\n for (let k, m) { sampled.push(prices[Math:floor(k * stride)]) }\n var mn = sampled[0]\n var mx = sampled[0]\n for (let j, m) {\n if (sampled[j] < mn) { mn = sampled[j] }\n if (sampled[j] > mx) { mx = sampled[j] }\n }\n let span = mx - mn\n var bars = \"\"\n for (let j, m) {\n let lvl = if (span == 0) { 0 } else { Math:round((sampled[j] - mn) / span * 7) }\n bars = `{bars}{blocks[lvl]}`\n }\n bars\n}\n\n// ─────────────────────────────\n// データ取得\n// ─────────────────────────────\n/// 1 銘柄を取得。result[0] (meta + indicators) を返す。失敗時 null。\n@fetchChart(symbol) {\n let enc = symbol.replace(\"^\", \"%5E\")\n let url = `https://query1.finance.yahoo.com/v8/finance/chart/{enc}?range={RANGE}&interval=1d`\n let res = Nd:http(url, {\n method: \"GET\"\n headers: { \"Accept\": \"application/json\" }\n })\n lastStatus = res.status\n if (res.status == 429) { sawRateLimit = true }\n if (res.status != 200) { return null }\n let doc = Json:parse(res.body)\n let rs = get(get(doc, \"chart\", {}), \"result\", [])\n if (Core:type(rs) != \"arr\" || rs.len == 0) { return null }\n rs[0]\n}\n\n/// result から終値配列を取り出す\n@closesOf(d) {\n let q = get(get(d, \"indicators\", {}), \"quote\", [])\n if (Core:type(q) != \"arr\" || q.len == 0) { return [] }\n cleanNums(get(q[0], \"close\", []))\n}\n\n/// 全銘柄を順に取得 (Yahoo chart は 1 リクエスト 1 銘柄)\n@fetchAll(symbols) {\n let list = symbols.replace(\" \", \"\").split(\",\")\n var rows = []\n for (let i, list.len) {\n let s = list[i]\n if (s != \"\") { rows.push({ symbol: s, data: fetchChart(s) }) }\n }\n rows\n}\n\n// ─────────────────────────────\n// UI 構築\n// ─────────────────────────────\n/// 1 銘柄 = 2 行カード (crypto ウィジェットと同じ体裁)\n/// 行1: 順位 名称 ……… 価格\n/// 行2: ▲前日比 ……… 5日スパークライン\n@stockCard(e) {\n let d = e.data\n let mt = get(d, \"meta\", {})\n let symbol = get(mt, \"symbol\", e.symbol)\n let price = get(mt, \"regularMarketPrice\", null)\n let prev = get(mt, \"chartPreviousClose\", null)\n let cur = get(mt, \"currency\", \"\")\n let itype = get(mt, \"instrumentType\", \"EQUITY\")\n let chg = if (Core:type(price) == \"null\" || Core:type(prev) == \"null\" || prev == 0) {\n null\n } else {\n (price - prev) / prev * 100\n }\n let col = chgColor(chg)\n let name = labelFor(e.symbol, mt)\n\n // 行1: 名称 ……… 価格\n let l1Plain = name\n let l1Show = `$[fg.color={COL_NAME} **{name}**]`\n let priceStr = fmtPrice(price, cur, itype)\n let r1Show = `$[fg.color={COL_PRICE} {priceStr}]`\n let line1 = justify(l1Plain, l1Show, priceStr, r1Show, WIDTH)\n\n // 行2: ▲前日比 ……… スパークライン\n let chgV = if (Core:type(chg) == \"null\") { \"-\" } else { `{round2(chg)}` }\n let l2Plain = ` {chgArrow(chg)} {chgV}%`\n let l2Show = ` $[fg.color={col} {chgArrow(chg)} {chgV}%]`\n let sp = sparkText(closesOf(d), 12)\n let r2Show = `$[fg.color={col} {sp}]`\n let line2 = justify(l2Plain, l2Show, sp, r2Show, WIDTH)\n\n [\n Ui:C:mfm({ text: line1, font: \"monospace\" })\n Ui:C:mfm({ text: line2, font: \"monospace\" })\n ]\n}\n\n/// 取得できなかった銘柄の行\n@missCard(symbol) {\n [Ui:C:mfm({ text: `$[fg.color={COL_FLAT} {symbol} — データなし]`, font: \"monospace\" })]\n}\n\n@stocksView(rows) {\n if (rows.len == 0) {\n return [Ui:C:mfm({ text: \"$[fg.color=8a919e 銘柄が設定されていません]\" })]\n }\n\n // タイトル行に更新時刻 (HH:MM) を統合\n let now = Date:now()\n var body = [\n Ui:C:mfm({ text: `**株価** $[fg.color={COL_DIM} 更新 {pad2(Date:hour(now))}:{pad2(Date:minute(now))}]` })\n ]\n\n // 区切り線なし。銘柄カードを連続表示\n for (let i, rows.len) {\n let e = rows[i]\n if (Core:type(e.data) != \"null\") {\n body = body.concat(stockCard(e))\n } else {\n body = body.concat(missCard(e.symbol))\n }\n }\n body\n}\n\nvar firstRun = true\nvar hasData = false // 一度でも価格表示に成功したか\n\n@render() {\n let symbols = DEFAULT_SYMBOLS\n sawRateLimit = false // この更新分の 429 観測をリセット\n\n if (firstRun) {\n Ui:render([\n Ui:C:container({\n align: \"center\"\n children: [Ui:C:text({ text: \"株価を読み込み中…\" })]\n })\n ])\n }\n firstRun = false\n\n let rows = fetchAll(symbols)\n\n // 全銘柄が取得失敗した場合のみエラー扱い\n var ok = 0\n for (let i, rows.len) { if (Core:type(rows[i].data) != \"null\") { ok = ok + 1 } }\n if (ok == 0) {\n // 既に表示があればそれを維持し、画面のチラつきを防ぐ。初回失敗時のみエラー表示。\n if (hasData) { return null }\n let hint = if (lastStatus == 429) { \"レート制限中。しばらく待つと回復します\" } else { \"通信エラー\" }\n Ui:render([\n Ui:C:container({\n align: \"center\"\n children: [\n Ui:C:mfm({ text: `$[fg.color=ea3943 ⚠️ 株価データを取得できません (HTTP {lastStatus})]` })\n Ui:C:mfm({ text: `$[fg.color={COL_FLAT} {hint}]` })\n ]\n })\n ])\n return null\n }\n hasData = true\n\n let children = stocksView(rows)\n Ui:render([Ui:C:container({ align: \"center\", children: children })])\n}\n\n// 自動更新: 通常 5 分間隔。Yahoo は 1 更新 = 銘柄数リクエストかつ制限が\n// 予測不能なため、429 を観測したら待機を倍々に延ばす (最大 30 分) バックオフを入れる。\n// 固定 1 分 tick + nextAt 判定にすることで interval の張り直し (再入) を避ける。\nlet BASE_MS = 300000 // 通常 5 分\nlet MAX_MS = 1800000 // 429 連発時の上限 30 分\nlet TICK_MS = 60000 // 判定は 1 分ごと (実 fetch は nextAt で 5 分間隔に制御)\nvar nextAt = 0\nvar curWait = BASE_MS\n\n@tick() {\n if (Date:now() < nextAt) { return null }\n render()\n curWait = if (sawRateLimit) { Math:min(curWait * 2, MAX_MS) } else { BASE_MS }\n nextAt = Date:now() + curWait\n}\n\nrender() // 初回即時\nnextAt = Date:now() + BASE_MS\nAsync:interval(TICK_MS, tick, false)\n"}
+{"type":"widget","data":"/// @ 1.2.1\n/// 株価ウィジェット\n/// Yahoo Finance の chart API で株価・指数・為替の現在値・前日比・5日トレンドを表示。API キー不要。\n/// v8/finance/chart エンドポイントを銘柄ごとに叩き、meta(現在値・前日終値)と\n/// 終値の時系列(スパークライン用)を取得する。\n/// 見た目は crypto ウィジェットと同じ monospace 整列レイアウト。\n/// 銘柄を変えるには下の DEFAULT_SYMBOLS を直接編集する (Yahoo Finance のティッカー)。\n/// 例: AAPL (米株) / 7203.T (日本株) / ^N225 (指数) / JPY=X (為替)\n///\n/// デフォルトは「AI・半導体・宇宙・クラウド」という今後の成長軸に寄せた厳選:\n/// ^N225 日経平均 — 国内の地合い\n/// JPY=X ドル円 — VPS/API 課金に直結する円相場\n/// NVDA NVIDIA — GPU、AI インフラの中心\n/// MSFT Microsoft — Azure / OpenAI 出資、AI クラウド\n/// GOOGL Alphabet — Gemini / 検索\n/// META Meta — Llama、ActivityPub(Threads) で Fedi とも接点\n/// SPCX SpaceX — 2026/6 上場、宇宙・Starlink\n/// (MANGOS のうち Anthropic / OpenAI は非上場のため対象外。\n/// 日本株(キオクシア 285A.T / さくらインターネット 3778.T) もティッカー指定で追加可)\n\nlet DEFAULT_SYMBOLS = \"^N225,JPY=X,NVDA,MSFT,GOOGL,META,SPCX\"\nlet RANGE = \"5d\" // スパークライン用の取得期間\nlet WIDTH = 24 // monospace 整列の桁幅 (半角換算)\n\nvar lastStatus = 0 // 直近の HTTP ステータス (エラー表示用)\n/// Nd:http の失敗理由。status 0 は通信断 (DNS 失敗・接続拒否・タイムアウト) で、\n/// NoteDeck は throw せず値で返す (notedeck#1294)\n@httpLabel(st) { if (st == 0) { \"通信できません\" } else { `HTTP {st}` } }\nvar sawRateLimit = false // 今回の更新でいずれかの銘柄が 429 を返したか (バックオフ判定用)\n\n// crypto ウィジェットと揃えたカラー\nlet COL_UP = \"16c784\"\nlet COL_DOWN = \"ea3943\"\nlet COL_FLAT = \"8a919e\"\nlet COL_DIM = \"5b6470\"\nlet COL_NAME = \"eff2f5\"\nlet COL_PRICE = \"d0d5dd\"\n\n// ─────────────────────────────\n// ヘルパー\n// ─────────────────────────────\n@dflt(v, d) { if (Core:type(v) == \"null\") d else v }\n\n@get(o, k, d) {\n if (Core:type(o) != \"obj\") { d }\n elif (Obj:has(o, k)) { dflt(o[k], d) }\n else { d }\n}\n\n@round2(x) {\n if (Core:type(x) == \"null\") { 0 }\n else { Math:round(x * 100) / 100 }\n}\n\n@pad2(n) { if (n < 10) { `0{n}` } else { `{n}` } }\n\n/// 同じ文字を n 個並べた文字列\n@repeatStr(ch, n) {\n var s = \"\"\n if (n > 0) { for (let i, n) { s = `{s}{ch}` } }\n s\n}\n\n/// 左セグメントと右セグメントを幅 w に揃える (間をスペース埋め)\n@justify(lPlain, lShow, rPlain, rShow, w) {\n let gap = w - lPlain.len - rPlain.len\n `{lShow}{repeatStr(\" \", if (gap > 0) { gap } else { 1 })}{rShow}`\n}\n\n/// 非負整数に3桁区切りのカンマを挿入 (再帰)\n@addCommas(n) {\n if (n < 1000) { `{n}` }\n else {\n let head = addCommas(Math:floor(n / 1000))\n let tail = n % 1000\n let t = if (tail < 10) { `00{tail}` } elif (tail < 100) { `0{tail}` } else { `{tail}` }\n `{head},{t}`\n }\n}\n\n@curSymbol(cur) {\n if (cur == \"JPY\") { \"¥\" }\n elif (cur == \"USD\") { \"$\" }\n elif (cur == \"EUR\") { \"€\" }\n elif (cur == \"GBP\") { \"£\" }\n else { \"\" }\n}\n\n/// 価格を商品種別に応じて整形\n/// CURRENCY(為替): 記号なし小数 (161.57)\n/// INDEX(指数) : 記号なしカンマ区切り (38,403)\n/// それ以外(株式) : 通貨記号 + カンマ区切り\n@fmtPrice(p, cur, itype) {\n if (Core:type(p) == \"null\") { \"-\" }\n elif (itype == \"CURRENCY\") { `{round2(p)}` }\n elif (itype == \"INDEX\") { `{addCommas(Math:round(p))}` }\n else {\n let sym = curSymbol(cur)\n if (p >= 100) { `{sym}{addCommas(Math:round(p))}` }\n elif (p >= 1) { `{sym}{round2(p)}` }\n else { `{sym}{Math:round(p * 100) / 100}` }\n }\n}\n\n/// ティッカー → 識別用の絵文字 (登録のある銘柄のみ)\n@emojiFor(symbol) {\n if (symbol == \"^N225\") { \"🎌\" }\n elif (symbol == \"JPY=X\") { \"💱\" }\n elif (symbol == \"3778.T\") { \"🌸\" }\n elif (symbol == \"285A.T\") { \"💾\" }\n elif (symbol == \"NVDA\") { \"🟩\" }\n elif (symbol == \"AAPL\") { \"🍎\" }\n elif (symbol == \"MSFT\") { \"🪟\" }\n elif (symbol == \"GOOGL\") { \"🔤\" }\n elif (symbol == \"AMZN\") { \"📦\" }\n elif (symbol == \"META\") { \"📘\" }\n elif (symbol == \"SPCX\") { \"🚀\" }\n else { \"\" }\n}\n\n/// 表示名 (Yahoo の shortName を優先、長ければ切り詰め)\n@labelFor(symbol, mt) {\n let nm = get(mt, \"shortName\", symbol)\n let nmShort = if (nm.len > 11) { nm.slice(0, 11) } else { nm }\n let e = emojiFor(symbol)\n if (e != \"\") { `{e} {nmShort}` } else { nmShort }\n}\n\n@chgArrow(chg) {\n if (Core:type(chg) == \"null\") { \"■\" }\n elif (chg > 0) { \"▲\" }\n elif (chg < 0) { \"▼\" }\n else { \"■\" }\n}\n\n@chgColor(chg) {\n if (Core:type(chg) == \"null\") { COL_FLAT }\n elif (chg > 0) { COL_UP }\n elif (chg < 0) { COL_DOWN }\n else { COL_FLAT }\n}\n\n/// 数値のみを抜き出す (Yahoo の close 配列は null を含むことがある)\n@cleanNums(arr) {\n if (Core:type(arr) != \"arr\") { return [] }\n var nums = []\n for (let i, arr.len) { if (Core:type(arr[i]) == \"num\") { nums.push(arr[i]) } }\n nums\n}\n\n/// 終値配列をブロック文字のスパークラインに変換 (素テキスト)\n@sparkText(prices, points) {\n if (Core:type(prices) != \"arr\") { return \"\" }\n let plen = prices.len\n if (plen < 2) { return \"\" }\n let blocks = [\"▁\", \"▂\", \"▃\", \"▄\", \"▅\", \"▆\", \"▇\", \"█\"]\n let m = if (plen > points) { points } else { plen }\n let stride = plen / m\n var sampled = []\n for (let k, m) { sampled.push(prices[Math:floor(k * stride)]) }\n var mn = sampled[0]\n var mx = sampled[0]\n for (let j, m) {\n if (sampled[j] < mn) { mn = sampled[j] }\n if (sampled[j] > mx) { mx = sampled[j] }\n }\n let span = mx - mn\n var bars = \"\"\n for (let j, m) {\n let lvl = if (span == 0) { 0 } else { Math:round((sampled[j] - mn) / span * 7) }\n bars = `{bars}{blocks[lvl]}`\n }\n bars\n}\n\n// ─────────────────────────────\n// データ取得\n// ─────────────────────────────\n/// 1 銘柄を取得。result[0] (meta + indicators) を返す。失敗時 null。\n@fetchChart(symbol) {\n let enc = symbol.replace(\"^\", \"%5E\")\n let url = `https://query1.finance.yahoo.com/v8/finance/chart/{enc}?range={RANGE}&interval=1d`\n let res = Nd:http(url, {\n method: \"GET\"\n headers: { \"Accept\": \"application/json\" }\n })\n lastStatus = res.status\n if (res.status == 429) { sawRateLimit = true }\n if (res.status != 200) { return null }\n let doc = Json:parse(res.body)\n let rs = get(get(doc, \"chart\", {}), \"result\", [])\n if (Core:type(rs) != \"arr\" || rs.len == 0) { return null }\n rs[0]\n}\n\n/// result から終値配列を取り出す\n@closesOf(d) {\n let q = get(get(d, \"indicators\", {}), \"quote\", [])\n if (Core:type(q) != \"arr\" || q.len == 0) { return [] }\n cleanNums(get(q[0], \"close\", []))\n}\n\n/// 全銘柄を順に取得 (Yahoo chart は 1 リクエスト 1 銘柄)\n@fetchAll(symbols) {\n let list = symbols.replace(\" \", \"\").split(\",\")\n var rows = []\n for (let i, list.len) {\n let s = list[i]\n if (s != \"\") { rows.push({ symbol: s, data: fetchChart(s) }) }\n }\n rows\n}\n\n// ─────────────────────────────\n// UI 構築\n// ─────────────────────────────\n/// 1 銘柄 = 2 行カード (crypto ウィジェットと同じ体裁)\n/// 行1: 順位 名称 ……… 価格\n/// 行2: ▲前日比 ……… 5日スパークライン\n@stockCard(e) {\n let d = e.data\n let mt = get(d, \"meta\", {})\n let symbol = get(mt, \"symbol\", e.symbol)\n let price = get(mt, \"regularMarketPrice\", null)\n let prev = get(mt, \"chartPreviousClose\", null)\n let cur = get(mt, \"currency\", \"\")\n let itype = get(mt, \"instrumentType\", \"EQUITY\")\n let chg = if (Core:type(price) == \"null\" || Core:type(prev) == \"null\" || prev == 0) {\n null\n } else {\n (price - prev) / prev * 100\n }\n let col = chgColor(chg)\n let name = labelFor(e.symbol, mt)\n\n // 行1: 名称 ……… 価格\n let l1Plain = name\n let l1Show = `$[fg.color={COL_NAME} **{name}**]`\n let priceStr = fmtPrice(price, cur, itype)\n let r1Show = `$[fg.color={COL_PRICE} {priceStr}]`\n let line1 = justify(l1Plain, l1Show, priceStr, r1Show, WIDTH)\n\n // 行2: ▲前日比 ……… スパークライン\n let chgV = if (Core:type(chg) == \"null\") { \"-\" } else { `{round2(chg)}` }\n let l2Plain = ` {chgArrow(chg)} {chgV}%`\n let l2Show = ` $[fg.color={col} {chgArrow(chg)} {chgV}%]`\n let sp = sparkText(closesOf(d), 12)\n let r2Show = `$[fg.color={col} {sp}]`\n let line2 = justify(l2Plain, l2Show, sp, r2Show, WIDTH)\n\n [\n Ui:C:mfm({ text: line1, font: \"monospace\" })\n Ui:C:mfm({ text: line2, font: \"monospace\" })\n ]\n}\n\n/// 取得できなかった銘柄の行\n@missCard(symbol) {\n [Ui:C:mfm({ text: `$[fg.color={COL_FLAT} {symbol} — データなし]`, font: \"monospace\" })]\n}\n\n@stocksView(rows) {\n if (rows.len == 0) {\n return [Ui:C:mfm({ text: \"$[fg.color=8a919e 銘柄が設定されていません]\" })]\n }\n\n // タイトル行に更新時刻 (HH:MM) を統合\n let now = Date:now()\n var body = [\n Ui:C:mfm({ text: `**株価** $[fg.color={COL_DIM} 更新 {pad2(Date:hour(now))}:{pad2(Date:minute(now))}]` })\n ]\n\n // 区切り線なし。銘柄カードを連続表示\n for (let i, rows.len) {\n let e = rows[i]\n if (Core:type(e.data) != \"null\") {\n body = body.concat(stockCard(e))\n } else {\n body = body.concat(missCard(e.symbol))\n }\n }\n body\n}\n\nvar firstRun = true\nvar hasData = false // 一度でも価格表示に成功したか\n\n@render() {\n let symbols = DEFAULT_SYMBOLS\n sawRateLimit = false // この更新分の 429 観測をリセット\n\n if (firstRun) {\n Ui:render([\n Ui:C:container({\n align: \"center\"\n children: [Ui:C:text({ text: \"株価を読み込み中…\" })]\n })\n ])\n }\n firstRun = false\n\n let rows = fetchAll(symbols)\n\n // 全銘柄が取得失敗した場合のみエラー扱い\n var ok = 0\n for (let i, rows.len) { if (Core:type(rows[i].data) != \"null\") { ok = ok + 1 } }\n if (ok == 0) {\n // 既に表示があればそれを維持し、画面のチラつきを防ぐ。初回失敗時のみエラー表示。\n if (hasData) { return null }\n let hint = if (lastStatus == 429) { \"レート制限中。しばらく待つと回復します\" } else { \"通信エラー\" }\n Ui:render([\n Ui:C:container({\n align: \"center\"\n children: [\n Ui:C:mfm({ text: `$[fg.color=ea3943 ⚠️ 株価データを取得できません ({httpLabel(lastStatus)})]` })\n Ui:C:mfm({ text: `$[fg.color={COL_FLAT} {hint}]` })\n ]\n })\n ])\n return null\n }\n hasData = true\n\n let children = stocksView(rows)\n Ui:render([Ui:C:container({ align: \"center\", children: children })])\n}\n\n// 自動更新: 通常 5 分間隔。Yahoo は 1 更新 = 銘柄数リクエストかつ制限が\n// 予測不能なため、429 を観測したら待機を倍々に延ばす (最大 30 分) バックオフを入れる。\n// 固定 1 分 tick + nextAt 判定にすることで interval の張り直し (再入) を避ける。\nlet BASE_MS = 300000 // 通常 5 分\nlet MAX_MS = 1800000 // 429 連発時の上限 30 分\nlet TICK_MS = 60000 // 判定は 1 分ごと (実 fetch は nextAt で 5 分間隔に制御)\nvar nextAt = 0\nvar curWait = BASE_MS\n\n@tick() {\n if (Date:now() < nextAt) { return null }\n render()\n curWait = if (sawRateLimit) { Math:min(curWait * 2, MAX_MS) } else { BASE_MS }\n nextAt = Date:now() + curWait\n}\n\nrender() // 初回即時\nnextAt = Date:now() + BASE_MS\nAsync:interval(TICK_MS, tick, false)\n"}
diff --git a/public/registry/widgets/stocks/widget.is b/public/registry/widgets/stocks/widget.is
index 2643e05..8c6696d 100644
--- a/public/registry/widgets/stocks/widget.is
+++ b/public/registry/widgets/stocks/widget.is
@@ -23,6 +23,9 @@ let RANGE = "5d" // スパークライン用の取得期間
let WIDTH = 24 // monospace 整列の桁幅 (半角換算)
var lastStatus = 0 // 直近の HTTP ステータス (エラー表示用)
+/// Nd:http の失敗理由。status 0 は通信断 (DNS 失敗・接続拒否・タイムアウト) で、
+/// NoteDeck は throw せず値で返す (notedeck#1294)
+@httpLabel(st) { if (st == 0) { "通信できません" } else { `HTTP {st}` } }
var sawRateLimit = false // 今回の更新でいずれかの銘柄が 429 を返したか (バックオフ判定用)
// crypto ウィジェットと揃えたカラー
@@ -309,7 +312,7 @@ var hasData = false // 一度でも価格表示に成功したか
Ui:C:container({
align: "center"
children: [
- Ui:C:mfm({ text: `$[fg.color=ea3943 ⚠️ 株価データを取得できません (HTTP {lastStatus})]` })
+ Ui:C:mfm({ text: `$[fg.color=ea3943 ⚠️ 株価データを取得できません ({httpLabel(lastStatus)})]` })
Ui:C:mfm({ text: `$[fg.color={COL_FLAT} {hint}]` })
]
})
diff --git a/public/registry/widgets/vocaloid-trending/api.json b/public/registry/widgets/vocaloid-trending/api.json
index f07c73b..2c93333 100644
--- a/public/registry/widgets/vocaloid-trending/api.json
+++ b/public/registry/widgets/vocaloid-trending/api.json
@@ -1 +1 @@
-{"type":"widget","data":"/// @ 1.2.1\n/// ボカロ 話題の曲 ウィジェット (ニコニコ動画 スナップショット検索 API v2)\n/// 直近に投稿された VOCALOID タグの動画を いいね / 再生 / マイリス / コメント順に並べ、\n/// 「今話題で評価の高い曲」を把握する。API キー不要 (Nd:http、初回に外部通信の確認が出る)。\n/// 各行はタイトルが nico.ms リンク、再生・いいね・マイリス・いいね率 (♥÷▶)・投稿日、\n/// 10 万再生以上には 🏆 (殿堂入り) を添える。共有はリンクを開いて URL を貼れば\n/// Misskey のリンクカードで足りるので、投稿ボタンは持たない。\n///\n/// 期間 (投稿日の範囲) とタグ、並び順は設定 folder から切り替えられ Mk:save に保存される。\n/// 30 分ごとに自動更新 (1 回 = 1 リクエスト)。スナップショット API の仕様:\n/// https://site.nicovideo.jp/search-api-docs/snapshot\n/// 設計の元ネタ: osu-stats (設定 UI + 更新ループ) / listenbrainz (投稿ボタン)。\n\nlet API = \"https://snapshot.search.nicovideo.jp/api/v2/snapshot/video/contents/search\"\nlet UA = \"misstore-vocaloid-trending/1.0 (https://github.com/notedeck-dev/misstore)\"\nlet LIMIT = 10\nlet REFRESH_MS = 1800000\n\nlet TAG_ITEMS = [\n { text: \"VOCALOID\", value: \"VOCALOID\" }\n { text: \"VOCALOID新曲リンク\", value: \"VOCALOID新曲リンク\" }\n { text: \"歌ってみた\", value: \"歌ってみた\" }\n { text: \"音MAD\", value: \"音MAD\" }\n]\nlet SORT_ITEMS = [\n { text: \"♥ いいね順\", value: \"likeCounter\" }\n { text: \"▶ 再生順\", value: \"viewCounter\" }\n { text: \"📁 マイリス順\", value: \"mylistCounter\" }\n { text: \"💬 コメント順\", value: \"commentCounter\" }\n]\nlet DAYS_ITEMS = [\n { text: \"24 時間\", value: \"1\" }\n { text: \"3 日\", value: \"3\" }\n { text: \"7 日\", value: \"7\" }\n { text: \"30 日\", value: \"30\" }\n]\n\n// カラーパレット\nlet MUTED = \"8a96a3\"\nlet LINE = \"5a6573\"\nlet ACCENT = \"39c5bb\" // ミクカラー\nlet GOLD = \"ffb300\"\nlet RED = \"ff6961\"\n\n// ─────────────────────────────\n// ヘルパー\n// ─────────────────────────────\n@dflt(v, d) { if (Core:type(v) == \"null\") d else v }\n\n@get(o, k, d) {\n if (Core:type(o) != \"obj\") { d }\n elif (Obj:has(o, k)) { dflt(o[k], d) }\n else { d }\n}\n\n@num(v) {\n if (Core:type(v) == \"num\") { return v }\n if (Core:type(v) != \"str\") { return 0 }\n let n = v.to_num()\n if (Core:type(n) == \"num\") { n } else { 0 }\n}\n\n/// スナップショット API のタイトルは HTML 実体参照 (& 等) を含む\n@decodeEntities(s) {\n var t = s.replace(\"<\", \"<\")\n t = t.replace(\">\", \">\")\n t = t.replace(\""\", \"\\\"\")\n t = t.replace(\"'\", \"'\")\n t = t.replace(\"'\", \"'\")\n t = t.replace(\"&\", \"&\")\n t\n}\n\n/// MFM 制御文字を無害化。タイトルは外部由来なので必ず通す\n@sanitizeText(s) {\n if (Core:type(s) != \"str\") { return \"(不明)\" }\n var txt = decodeEntities(s)\n txt = txt.replace(\"$[\", \"$ \")\n txt = txt.replace(\"]\", \"]\")\n txt = txt.replace(\"[\", \"[\")\n txt = txt.replace(\"(\", \"(\")\n txt = txt.replace(\")\", \")\")\n txt = txt.replace(\"<\", \"<\")\n txt = txt.replace(\">\", \">\")\n txt = txt.replace(\"`\", \"`\")\n txt = txt.replace(\"*\", \"*\")\n txt = txt.replace(\"~\", \"〜\")\n txt = txt.replace(\"\\\\\", \"\\")\n txt = txt.replace(\"@\", \"@\")\n txt = txt.replace(\"#\", \"#\")\n txt = txt.replace(\"://\", \"://\")\n txt\n}\n\n@truncName(s, n) {\n if (s.len > n) { `{s.slice(0, n)}…` } else { s }\n}\n\n/// 桁区切り。1234567 → \"1,234,567\"\n@fmtInt(n) {\n let s = Core:to_str(Math:round(num(n)))\n var out1 = \"\"\n var cnt = 0\n var i = s.len\n loop {\n if (i <= 0) { break }\n out1 = `{s.slice(i - 1, i)}{out1}`\n cnt = cnt + 1\n i = i - 1\n if (cnt % 3 == 0 && i > 0) { out1 = `,{out1}` }\n }\n out1\n}\n\n/// 1 万以上は \"12.5万\"、未満は桁区切り\n@fmtCount(n) {\n let v = num(n)\n if (v < 10000) { return fmtInt(v) }\n let tenths = Math:round(v / 1000) // 万の 1/10 単位\n let whole = Math:floor(tenths / 10)\n let frac = tenths % 10\n if (frac == 0) { `{whole}万` } else { `{whole}.{frac}万` }\n}\n\n/// 小数 1 桁の百分率 \"4.4%\"\n@fmtRate(part, total) {\n if (num(total) <= 0) { return \"-\" }\n let tenths = Math:round(num(part) * 1000 / num(total))\n `{Math:floor(tenths / 10)}.{tenths % 10}%`\n}\n\n@ago(ms) {\n let diff = Date:now() - ms\n if (diff < 3600000) { `{Math:max(1, Math:floor(diff / 60000))} 分前` }\n elif (diff < 86400000) { `{Math:floor(diff / 3600000)} 時間前` }\n else { `{Math:floor(diff / 86400000)} 日前` }\n}\n\n@divider() {\n Ui:C:mfm({ text: `$[fg.color={LINE} ───────────────]` })\n}\n\n@labelOf(items, value, fallback) {\n var found = fallback\n each (let it, items) { if (it.value == value) { found = it.text } }\n found\n}\n\n// ─────────────────────────────\n// 状態\n// ─────────────────────────────\nvar tag = dflt(Mk:load(\"vocaTag\"), \"VOCALOID\")\nvar days = num(dflt(Mk:load(\"vocaDays\"), 7))\nvar sortKey = dflt(Mk:load(\"vocaSort\"), \"likeCounter\")\nvar result = null // null = 未取得 / { error } / { rows, total, fetchedAt }\nvar firstRun = true\n\n// ─────────────────────────────\n// データ取得\n// ─────────────────────────────\n@buildUrl() {\n let since = Date:to_iso_str(Date:now() - days * 86400000, 540)\n var u = `{API}?q={Uri:encode_component(tag)}&targets=tagsExact`\n u = `{u}&fields=contentId,title,viewCounter,mylistCounter,likeCounter,commentCounter,lengthSeconds,startTime`\n u = `{u}&filters%5BstartTime%5D%5Bgte%5D={Uri:encode_component(since)}`\n u = `{u}&_sort=-{sortKey}&_limit={LIMIT}&_context=misstore-vocaloid-trending`\n u\n}\n\n@toRow(v) {\n let startMs = Date:parse(get(v, \"startTime\", \"\"))\n {\n id: get(v, \"contentId\", \"\")\n title: sanitizeText(get(v, \"title\", \"\"))\n views: num(get(v, \"viewCounter\", 0))\n likes: num(get(v, \"likeCounter\", 0))\n mylists: num(get(v, \"mylistCounter\", 0))\n comments: num(get(v, \"commentCounter\", 0))\n startMs: if (Core:type(startMs) == \"num\") { startMs } else { 0 }\n }\n}\n\n@fetchData() {\n let res = Nd:http(buildUrl(), {\n method: \"GET\"\n headers: { \"Accept\": \"application/json\", \"User-Agent\": UA }\n })\n if (res.status != 200) {\n result = { error: `HTTP {res.status}` }\n render()\n return null\n }\n let data = Json:parse(res.body)\n if (Core:type(data) != \"obj\") {\n result = { error: \"応答を解釈できません\" }\n render()\n return null\n }\n let list = get(data, \"data\", [])\n var rows = []\n each (let v, list) { rows.push(toRow(v)) }\n result = { rows: rows, total: num(get(get(data, \"meta\", {}), \"totalCount\", rows.len)), fetchedAt: Date:now() }\n render()\n}\n\n// ─────────────────────────────\n// UI\n// ─────────────────────────────\n@configFolder() {\n Ui:C:folder({\n title: \"⚙️ 設定\"\n opened: false\n children: [\n Ui:C:select({\n items: TAG_ITEMS\n default: tag\n label: \"タグ\"\n onChange: @(v) { tag = v; Mk:save(\"vocaTag\", v) }\n })\n Ui:C:select({\n items: DAYS_ITEMS\n default: Core:to_str(days)\n label: \"期間 (投稿日)\"\n onChange: @(v) { days = num(v); Mk:save(\"vocaDays\", days) }\n })\n Ui:C:select({\n items: SORT_ITEMS\n default: sortKey\n label: \"並び順\"\n onChange: @(v) { sortKey = v; Mk:save(\"vocaSort\", v) }\n })\n Ui:C:button({ text: \"適用して再読み込み\", onClick: @() { fetchData() } })\n ]\n })\n}\n\n/// 文字の繰り返し (str に repeat は無い)\n@rep(ch, n) {\n var s = \"\"\n for (n) { s = `{s}{ch}` }\n s\n}\n\n/// いいね率の 5 マスバー (10% で満タン)。等幅で崩れないように font.monospace\n@rateBar(part, total) {\n if (num(total) <= 0) { return \"\" }\n let filled = Math:min(5, Math:max(0, Math:round(num(part) * 50 / num(total))))\n let on = if (filled > 0) { `$[fg.color={ACCENT} {rep(\"▰\", filled)}]` } else { \"\" }\n let off = if (filled < 5) { `$[fg.color={LINE} {rep(\"▱\", 5 - filled)}]` } else { \"\" }\n `$[font.monospace {on}{off}]`\n}\n\n/// 順位チップ。1〜3 位は金・銀・銅の塗り、以降は薄い数字\n@rankChip(i) {\n let n = i + 1\n if (n == 1) { `$[border.radius=4 $[bg.color={GOLD} $[fg.color=0b0d12 ** 1 **]]]` }\n elif (n == 2) { `$[border.radius=4 $[bg.color=c8d0d8 $[fg.color=0b0d12 ** 2 **]]]` }\n elif (n == 3) { `$[border.radius=4 $[bg.color=c47a4a $[fg.color=0b0d12 ** 3 **]]]` }\n else { `$[fg.color={MUTED} **{n}.**]` }\n}\n\n/// 再生数の到達チップ (殿堂入り 10 万 / 伝説入り 100 万 / 神話入り 1000 万)\n@milestone(views) {\n if (views >= 10000000) { ` $[rainbow **🌟神話**]` }\n elif (views >= 1000000) { ` $[fg.color={GOLD} **👑伝説**]` }\n elif (views >= 100000) { ` $[fg.color={GOLD} **🏆殿堂**]` }\n else { \"\" }\n}\n\n/// 並び順に対応する主役の値 (\"♥ 5,527\" など)\n@sortValue(r) {\n if (sortKey == \"viewCounter\") { `▶ {fmtInt(r.views)}` }\n elif (sortKey == \"mylistCounter\") { `📁 {fmtInt(r.mylists)}` }\n elif (sortKey == \"commentCounter\") { `💬 {fmtInt(r.comments)}` }\n else { `♥ {fmtInt(r.likes)}` }\n}\n\n@statsLine(r) {\n let whenTxt = if (r.startMs > 0) { ` · {ago(r.startMs)}` } else { \"\" }\n `▶{fmtCount(r.views)} ♥{fmtCount(r.likes)} 📁{fmtCount(r.mylists)} · ♥率 {fmtRate(r.likes, r.views)} {rateBar(r.likes, r.views)}{whenTxt}`\n}\n\n/// 1 位は主役として大きく: 見出し → x2 の値 → 小さな補足\n@heroView(r) {\n [\n Ui:C:mfm({ text: `{rankChip(0)} [{truncName(r.title, 36)}](https://nico.ms/{r.id}){milestone(r.views)}` })\n Ui:C:mfm({ text: `$[x2 $[fg.color={ACCENT} **{sortValue(r)}**]]` })\n Ui:C:mfm({ text: `$[fg.color={MUTED} {statsLine(r)}]` })\n ]\n}\n\n@rowView(i, r) {\n let l1 = `{rankChip(i)} [{truncName(r.title, 40)}](https://nico.ms/{r.id}){milestone(r.views)}`\n let l2 = `$[fg.color={MUTED} {statsLine(r)}]`\n Ui:C:mfm({ text: `{l1}{Str:lf}{l2}` })\n}\n\n@listView(res) {\n let sortLabel = labelOf(SORT_ITEMS, sortKey, sortKey)\n let daysLabel = labelOf(DAYS_ITEMS, Core:to_str(days), `{days} 日`)\n var body = [\n Ui:C:mfm({ text: `$[fg.color={MUTED} 🎧 {tag} · 直近 {daysLabel} · {sortLabel}]` })\n ]\n if (res.rows.len == 0) {\n body.push(Ui:C:mfm({ text: `$[x2 📭]` }))\n body.push(Ui:C:mfm({ text: `$[fg.color={MUTED} 該当なし。期間を延ばすかタグを変えてみてください]` }))\n return body\n }\n body = body.concat(heroView(res.rows[0]))\n if (res.rows.len > 1) {\n body.push(divider())\n var rows = []\n for (let i, res.rows.len) {\n if (i > 0) { rows.push(rowView(i, res.rows[i])) }\n }\n // 全体は中央寄せだが、一覧は左寄せの方が読みやすい\n body.push(Ui:C:container({ align: \"left\", children: rows }))\n }\n body\n}\n\n@render() {\n if (firstRun && Core:type(result) == \"null\") {\n Ui:render([\n Ui:C:container({ align: \"center\", padding: 10, children: [ Ui:C:text({ text: \"話題のボカロ曲を読み込み中… 🎧\" }) ] })\n ])\n firstRun = false\n fetchData()\n return null\n }\n\n var children = []\n if (Core:type(result) == \"null\") {\n children = [ Ui:C:container({ align: \"center\", children: [\n Ui:C:button({ text: \"🔄 更新\", primary: true, rounded: true, onClick: @() { fetchData() } })\n ] }) ]\n } elif (Obj:has(result, \"error\")) {\n children = [\n Ui:C:mfm({ text: `$[fg.color={RED} ⚠️ 取得できません ({result.error})]` })\n Ui:C:mfm({ text: `$[fg.color={MUTED} スナップショット検索 API が応答していないか、外部通信の確認で拒否された可能性があります]` })\n configFolder()\n Ui:C:container({ align: \"center\", children: [\n Ui:C:button({ text: \"🔄 再試行\", primary: true, rounded: true, onClick: @() { fetchData() } })\n ] })\n ]\n } else {\n children = listView(result)\n children.push(divider())\n children.push(configFolder())\n children.push(Ui:C:container({ align: \"center\", children: [\n Ui:C:button({ text: \"🔄 更新\", rounded: true, onClick: @() { fetchData() } })\n ] }))\n children.push(Ui:C:mfm({ text: `$[fg.color={MUTED} 全 {fmtInt(result.total)} 件 · スナップショット検索 API · 30 分ごとに更新]` }))\n }\n Ui:render([ Ui:C:container({ align: \"center\", padding: 10, children: children }) ])\n}\n\nrender()\nAsync:interval(REFRESH_MS, fetchData, false)\n"}
+{"type":"widget","data":"/// @ 1.2.1\n/// ボカロ 話題の曲 ウィジェット (ニコニコ動画 スナップショット検索 API v2)\n/// 直近に投稿された VOCALOID タグの動画を いいね / 再生 / マイリス / コメント順に並べ、\n/// 「今話題で評価の高い曲」を把握する。API キー不要 (Nd:http、初回に外部通信の確認が出る)。\n/// 各行はタイトルが nico.ms リンク、再生・いいね・マイリス・いいね率 (♥÷▶)・投稿日、\n/// 10 万再生以上には 🏆 (殿堂入り) を添える。共有はリンクを開いて URL を貼れば\n/// Misskey のリンクカードで足りるので、投稿ボタンは持たない。\n///\n/// 期間 (投稿日の範囲) とタグ、並び順は設定 folder から切り替えられ Mk:save に保存される。\n/// 30 分ごとに自動更新 (1 回 = 1 リクエスト)。スナップショット API の仕様:\n/// https://site.nicovideo.jp/search-api-docs/snapshot\n/// 設計の元ネタ: osu-stats (設定 UI + 更新ループ) / listenbrainz (投稿ボタン)。\n\nlet API = \"https://snapshot.search.nicovideo.jp/api/v2/snapshot/video/contents/search\"\nlet UA = \"misstore-vocaloid-trending/1.0 (https://github.com/notedeck-dev/misstore)\"\nlet LIMIT = 10\nlet REFRESH_MS = 1800000\n\nlet TAG_ITEMS = [\n { text: \"VOCALOID\", value: \"VOCALOID\" }\n { text: \"VOCALOID新曲リンク\", value: \"VOCALOID新曲リンク\" }\n { text: \"歌ってみた\", value: \"歌ってみた\" }\n { text: \"音MAD\", value: \"音MAD\" }\n]\nlet SORT_ITEMS = [\n { text: \"♥ いいね順\", value: \"likeCounter\" }\n { text: \"▶ 再生順\", value: \"viewCounter\" }\n { text: \"📁 マイリス順\", value: \"mylistCounter\" }\n { text: \"💬 コメント順\", value: \"commentCounter\" }\n]\nlet DAYS_ITEMS = [\n { text: \"24 時間\", value: \"1\" }\n { text: \"3 日\", value: \"3\" }\n { text: \"7 日\", value: \"7\" }\n { text: \"30 日\", value: \"30\" }\n]\n\n// カラーパレット\nlet MUTED = \"8a96a3\"\nlet LINE = \"5a6573\"\nlet ACCENT = \"39c5bb\" // ミクカラー\nlet GOLD = \"ffb300\"\nlet RED = \"ff6961\"\n\n// ─────────────────────────────\n// ヘルパー\n// ─────────────────────────────\n@dflt(v, d) { if (Core:type(v) == \"null\") d else v }\n\n@get(o, k, d) {\n if (Core:type(o) != \"obj\") { d }\n elif (Obj:has(o, k)) { dflt(o[k], d) }\n else { d }\n}\n\n@num(v) {\n if (Core:type(v) == \"num\") { return v }\n if (Core:type(v) != \"str\") { return 0 }\n let n = v.to_num()\n if (Core:type(n) == \"num\") { n } else { 0 }\n}\n\n/// スナップショット API のタイトルは HTML 実体参照 (& 等) を含む\n@decodeEntities(s) {\n var t = s.replace(\"<\", \"<\")\n t = t.replace(\">\", \">\")\n t = t.replace(\""\", \"\\\"\")\n t = t.replace(\"'\", \"'\")\n t = t.replace(\"'\", \"'\")\n t = t.replace(\"&\", \"&\")\n t\n}\n\n/// MFM 制御文字を無害化。タイトルは外部由来なので必ず通す\n@sanitizeText(s) {\n if (Core:type(s) != \"str\") { return \"(不明)\" }\n var txt = decodeEntities(s)\n txt = txt.replace(\"$[\", \"$ \")\n txt = txt.replace(\"]\", \"]\")\n txt = txt.replace(\"[\", \"[\")\n txt = txt.replace(\"(\", \"(\")\n txt = txt.replace(\")\", \")\")\n txt = txt.replace(\"<\", \"<\")\n txt = txt.replace(\">\", \">\")\n txt = txt.replace(\"`\", \"`\")\n txt = txt.replace(\"*\", \"*\")\n txt = txt.replace(\"~\", \"〜\")\n txt = txt.replace(\"\\\\\", \"\\")\n txt = txt.replace(\"@\", \"@\")\n txt = txt.replace(\"#\", \"#\")\n txt = txt.replace(\"://\", \"://\")\n txt\n}\n\n@truncName(s, n) {\n if (s.len > n) { `{s.slice(0, n)}…` } else { s }\n}\n\n/// 桁区切り。1234567 → \"1,234,567\"\n@fmtInt(n) {\n let s = Core:to_str(Math:round(num(n)))\n var out1 = \"\"\n var cnt = 0\n var i = s.len\n loop {\n if (i <= 0) { break }\n out1 = `{s.slice(i - 1, i)}{out1}`\n cnt = cnt + 1\n i = i - 1\n if (cnt % 3 == 0 && i > 0) { out1 = `,{out1}` }\n }\n out1\n}\n\n/// 1 万以上は \"12.5万\"、未満は桁区切り\n@fmtCount(n) {\n let v = num(n)\n if (v < 10000) { return fmtInt(v) }\n let tenths = Math:round(v / 1000) // 万の 1/10 単位\n let whole = Math:floor(tenths / 10)\n let frac = tenths % 10\n if (frac == 0) { `{whole}万` } else { `{whole}.{frac}万` }\n}\n\n/// 小数 1 桁の百分率 \"4.4%\"\n@fmtRate(part, total) {\n if (num(total) <= 0) { return \"-\" }\n let tenths = Math:round(num(part) * 1000 / num(total))\n `{Math:floor(tenths / 10)}.{tenths % 10}%`\n}\n\n@ago(ms) {\n let diff = Date:now() - ms\n if (diff < 3600000) { `{Math:max(1, Math:floor(diff / 60000))} 分前` }\n elif (diff < 86400000) { `{Math:floor(diff / 3600000)} 時間前` }\n else { `{Math:floor(diff / 86400000)} 日前` }\n}\n\n@divider() {\n Ui:C:mfm({ text: `$[fg.color={LINE} ───────────────]` })\n}\n\n@labelOf(items, value, fallback) {\n var found = fallback\n each (let it, items) { if (it.value == value) { found = it.text } }\n found\n}\n\n// ─────────────────────────────\n// 状態\n// ─────────────────────────────\nvar tag = dflt(Mk:load(\"vocaTag\"), \"VOCALOID\")\nvar days = num(dflt(Mk:load(\"vocaDays\"), 7))\nvar sortKey = dflt(Mk:load(\"vocaSort\"), \"likeCounter\")\nvar result = null // null = 未取得 / { error } / { rows, total, fetchedAt }\nvar firstRun = true\n\n// ─────────────────────────────\n// データ取得\n// ─────────────────────────────\n@buildUrl() {\n let since = Date:to_iso_str(Date:now() - days * 86400000, 540)\n var u = `{API}?q={Uri:encode_component(tag)}&targets=tagsExact`\n u = `{u}&fields=contentId,title,viewCounter,mylistCounter,likeCounter,commentCounter,lengthSeconds,startTime`\n u = `{u}&filters%5BstartTime%5D%5Bgte%5D={Uri:encode_component(since)}`\n u = `{u}&_sort=-{sortKey}&_limit={LIMIT}&_context=misstore-vocaloid-trending`\n u\n}\n\n@toRow(v) {\n let startMs = Date:parse(get(v, \"startTime\", \"\"))\n {\n id: get(v, \"contentId\", \"\")\n title: sanitizeText(get(v, \"title\", \"\"))\n views: num(get(v, \"viewCounter\", 0))\n likes: num(get(v, \"likeCounter\", 0))\n mylists: num(get(v, \"mylistCounter\", 0))\n comments: num(get(v, \"commentCounter\", 0))\n startMs: if (Core:type(startMs) == \"num\") { startMs } else { 0 }\n }\n}\n\n@fetchData() {\n let res = Nd:http(buildUrl(), {\n method: \"GET\"\n headers: { \"Accept\": \"application/json\", \"User-Agent\": UA }\n })\n if (res.status != 200) {\n // status 0 は通信断 (NoteDeck は throw せず値で返す、notedeck#1294)\n result = { error: if (res.status == 0) { \"通信できません\" } else { `HTTP {res.status}` } }\n render()\n return null\n }\n let data = Json:parse(res.body)\n if (Core:type(data) != \"obj\") {\n result = { error: \"応答を解釈できません\" }\n render()\n return null\n }\n let list = get(data, \"data\", [])\n var rows = []\n each (let v, list) { rows.push(toRow(v)) }\n result = { rows: rows, total: num(get(get(data, \"meta\", {}), \"totalCount\", rows.len)), fetchedAt: Date:now() }\n render()\n}\n\n// ─────────────────────────────\n// UI\n// ─────────────────────────────\n@configFolder() {\n Ui:C:folder({\n title: \"⚙️ 設定\"\n opened: false\n children: [\n Ui:C:select({\n items: TAG_ITEMS\n default: tag\n label: \"タグ\"\n onChange: @(v) { tag = v; Mk:save(\"vocaTag\", v) }\n })\n Ui:C:select({\n items: DAYS_ITEMS\n default: Core:to_str(days)\n label: \"期間 (投稿日)\"\n onChange: @(v) { days = num(v); Mk:save(\"vocaDays\", days) }\n })\n Ui:C:select({\n items: SORT_ITEMS\n default: sortKey\n label: \"並び順\"\n onChange: @(v) { sortKey = v; Mk:save(\"vocaSort\", v) }\n })\n Ui:C:button({ text: \"適用して再読み込み\", onClick: @() { fetchData() } })\n ]\n })\n}\n\n/// 文字の繰り返し (str に repeat は無い)\n@rep(ch, n) {\n var s = \"\"\n for (n) { s = `{s}{ch}` }\n s\n}\n\n/// いいね率の 5 マスバー (10% で満タン)。等幅で崩れないように font.monospace\n@rateBar(part, total) {\n if (num(total) <= 0) { return \"\" }\n let filled = Math:min(5, Math:max(0, Math:round(num(part) * 50 / num(total))))\n let on = if (filled > 0) { `$[fg.color={ACCENT} {rep(\"▰\", filled)}]` } else { \"\" }\n let off = if (filled < 5) { `$[fg.color={LINE} {rep(\"▱\", 5 - filled)}]` } else { \"\" }\n `$[font.monospace {on}{off}]`\n}\n\n/// 順位チップ。1〜3 位は金・銀・銅の塗り、以降は薄い数字\n@rankChip(i) {\n let n = i + 1\n if (n == 1) { `$[border.radius=4 $[bg.color={GOLD} $[fg.color=0b0d12 ** 1 **]]]` }\n elif (n == 2) { `$[border.radius=4 $[bg.color=c8d0d8 $[fg.color=0b0d12 ** 2 **]]]` }\n elif (n == 3) { `$[border.radius=4 $[bg.color=c47a4a $[fg.color=0b0d12 ** 3 **]]]` }\n else { `$[fg.color={MUTED} **{n}.**]` }\n}\n\n/// 再生数の到達チップ (殿堂入り 10 万 / 伝説入り 100 万 / 神話入り 1000 万)\n@milestone(views) {\n if (views >= 10000000) { ` $[rainbow **🌟神話**]` }\n elif (views >= 1000000) { ` $[fg.color={GOLD} **👑伝説**]` }\n elif (views >= 100000) { ` $[fg.color={GOLD} **🏆殿堂**]` }\n else { \"\" }\n}\n\n/// 並び順に対応する主役の値 (\"♥ 5,527\" など)\n@sortValue(r) {\n if (sortKey == \"viewCounter\") { `▶ {fmtInt(r.views)}` }\n elif (sortKey == \"mylistCounter\") { `📁 {fmtInt(r.mylists)}` }\n elif (sortKey == \"commentCounter\") { `💬 {fmtInt(r.comments)}` }\n else { `♥ {fmtInt(r.likes)}` }\n}\n\n@statsLine(r) {\n let whenTxt = if (r.startMs > 0) { ` · {ago(r.startMs)}` } else { \"\" }\n `▶{fmtCount(r.views)} ♥{fmtCount(r.likes)} 📁{fmtCount(r.mylists)} · ♥率 {fmtRate(r.likes, r.views)} {rateBar(r.likes, r.views)}{whenTxt}`\n}\n\n/// 1 位は主役として大きく: 見出し → x2 の値 → 小さな補足\n@heroView(r) {\n [\n Ui:C:mfm({ text: `{rankChip(0)} [{truncName(r.title, 36)}](https://nico.ms/{r.id}){milestone(r.views)}` })\n Ui:C:mfm({ text: `$[x2 $[fg.color={ACCENT} **{sortValue(r)}**]]` })\n Ui:C:mfm({ text: `$[fg.color={MUTED} {statsLine(r)}]` })\n ]\n}\n\n@rowView(i, r) {\n let l1 = `{rankChip(i)} [{truncName(r.title, 40)}](https://nico.ms/{r.id}){milestone(r.views)}`\n let l2 = `$[fg.color={MUTED} {statsLine(r)}]`\n Ui:C:mfm({ text: `{l1}{Str:lf}{l2}` })\n}\n\n@listView(res) {\n let sortLabel = labelOf(SORT_ITEMS, sortKey, sortKey)\n let daysLabel = labelOf(DAYS_ITEMS, Core:to_str(days), `{days} 日`)\n var body = [\n Ui:C:mfm({ text: `$[fg.color={MUTED} 🎧 {tag} · 直近 {daysLabel} · {sortLabel}]` })\n ]\n if (res.rows.len == 0) {\n body.push(Ui:C:mfm({ text: `$[x2 📭]` }))\n body.push(Ui:C:mfm({ text: `$[fg.color={MUTED} 該当なし。期間を延ばすかタグを変えてみてください]` }))\n return body\n }\n body = body.concat(heroView(res.rows[0]))\n if (res.rows.len > 1) {\n body.push(divider())\n var rows = []\n for (let i, res.rows.len) {\n if (i > 0) { rows.push(rowView(i, res.rows[i])) }\n }\n // 全体は中央寄せだが、一覧は左寄せの方が読みやすい\n body.push(Ui:C:container({ align: \"left\", children: rows }))\n }\n body\n}\n\n@render() {\n if (firstRun && Core:type(result) == \"null\") {\n Ui:render([\n Ui:C:container({ align: \"center\", padding: 10, children: [ Ui:C:text({ text: \"話題のボカロ曲を読み込み中… 🎧\" }) ] })\n ])\n firstRun = false\n fetchData()\n return null\n }\n\n var children = []\n if (Core:type(result) == \"null\") {\n children = [ Ui:C:container({ align: \"center\", children: [\n Ui:C:button({ text: \"🔄 更新\", primary: true, rounded: true, onClick: @() { fetchData() } })\n ] }) ]\n } elif (Obj:has(result, \"error\")) {\n children = [\n Ui:C:mfm({ text: `$[fg.color={RED} ⚠️ 取得できません ({result.error})]` })\n Ui:C:mfm({ text: `$[fg.color={MUTED} スナップショット検索 API が応答していないか、外部通信の確認で拒否された可能性があります]` })\n configFolder()\n Ui:C:container({ align: \"center\", children: [\n Ui:C:button({ text: \"🔄 再試行\", primary: true, rounded: true, onClick: @() { fetchData() } })\n ] })\n ]\n } else {\n children = listView(result)\n children.push(divider())\n children.push(configFolder())\n children.push(Ui:C:container({ align: \"center\", children: [\n Ui:C:button({ text: \"🔄 更新\", rounded: true, onClick: @() { fetchData() } })\n ] }))\n children.push(Ui:C:mfm({ text: `$[fg.color={MUTED} 全 {fmtInt(result.total)} 件 · スナップショット検索 API · 30 分ごとに更新]` }))\n }\n Ui:render([ Ui:C:container({ align: \"center\", padding: 10, children: children }) ])\n}\n\nrender()\nAsync:interval(REFRESH_MS, fetchData, false)\n"}
diff --git a/public/registry/widgets/vocaloid-trending/widget.is b/public/registry/widgets/vocaloid-trending/widget.is
index 3edfb15..6f81bbf 100644
--- a/public/registry/widgets/vocaloid-trending/widget.is
+++ b/public/registry/widgets/vocaloid-trending/widget.is
@@ -186,7 +186,8 @@ var firstRun = true
headers: { "Accept": "application/json", "User-Agent": UA }
})
if (res.status != 200) {
- result = { error: `HTTP {res.status}` }
+ // status 0 は通信断 (NoteDeck は throw せず値で返す、notedeck#1294)
+ result = { error: if (res.status == 0) { "通信できません" } else { `HTTP {res.status}` } }
render()
return null
}
diff --git a/public/registry/widgets/weather/api.json b/public/registry/widgets/weather/api.json
index 25f1b8d..a5e7350 100644
--- a/public/registry/widgets/weather/api.json
+++ b/public/registry/widgets/weather/api.json
@@ -1 +1 @@
-{"type":"widget","data":"/// @ 1.2.1\n/// 天気予報ウィジェット\n/// Open-Meteo (https://open-meteo.com/) で現在の天気と数日分の予報を表示。\n/// 都市名から geocoding で座標を引くため API キーは不要。\n/// 元ネタ: misskey-tempura WidgetWeather (lqvp) を Open-Meteo + AiScript で再実装。\n\n// 表示する場所はここを直接書き換える (ローマ字の検索クエリ)。\n// 例: 東京→Tokyo / 札幌→Sapporo / 金沢→Kanazawa\nlet CITY = \"Kawasaki\"\n\n@dflt(v, d) {\n if (Core:type(v) == \"null\") d else v\n}\n\n@get(o, k, d) {\n if (Core:type(o) != \"obj\") {\n d\n } elif (Obj:has(o, k)) {\n dflt(o[k], d)\n } else {\n d\n }\n}\n\nvar firstRun = true\n\n/// WMO weather code を絵文字に変換\n@wmoIcon(code) {\n if (code == 0) \"☀️\"\n elif (code == 1) \"🌤️\"\n elif (code == 2) \"⛅\"\n elif (code == 3) \"☁️\"\n elif (code == 45 || code == 48) \"🌫️\"\n elif (code >= 51 && code <= 57) \"🌦️\"\n elif (code >= 61 && code <= 67) \"🌧️\"\n elif (code >= 71 && code <= 77) \"🌨️\"\n elif (code >= 80 && code <= 82) \"🌦️\"\n elif (code == 85 || code == 86) \"🌨️\"\n elif (code >= 95) \"⛈️\"\n else \"❓\"\n}\n\n/// WMO weather code を日本語ラベルに変換\n@wmoLabel(code) {\n if (code == 0) \"快晴\"\n elif (code == 1) \"晴れ\"\n elif (code == 2) \"晴れ時々曇り\"\n elif (code == 3) \"曇り\"\n elif (code == 45 || code == 48) \"霧\"\n elif (code >= 51 && code <= 57) \"霧雨\"\n elif (code >= 61 && code <= 67) \"雨\"\n elif (code >= 71 && code <= 77) \"雪\"\n elif (code >= 80 && code <= 82) \"にわか雨\"\n elif (code == 85 || code == 86) \"にわか雪\"\n elif (code >= 95) \"雷雨\"\n else \"不明\"\n}\n\n/// 日次予報の行ラベル (今日 / 明日)\n@dayLabel(i, dateStr) {\n if (i == 0) \"今日\"\n elif (i == 1) \"明日\"\n else dateStr\n}\n\n@geocode(name) {\n let url = `https://geocoding-api.open-meteo.com/v1/search?name={name}&count=1&language=ja&format=json`\n let res = Nd:http(url, { method: \"GET\" })\n let data = Json:parse(res.body)\n let results = get(data, \"results\", [])\n if (results.len == 0) null else results[0]\n}\n\n@fetchForecast(lat, lon) {\n let url = `https://api.open-meteo.com/v1/forecast?latitude={lat}&longitude={lon}¤t=temperature_2m,weather_code,precipitation&daily=weather_code,temperature_2m_max,temperature_2m_min,precipitation_probability_max&timezone=auto&forecast_days=2`\n let res = Nd:http(url, { method: \"GET\" })\n Json:parse(res.body)\n}\n\n@weatherView() {\n let place = geocode(CITY)\n if (Core:type(place) == \"null\") {\n [\n Ui:C:mfm({ text: `$[fg.color=f74e52 ⚠️ 「{CITY}」が見つかりませんでした]` })\n Ui:C:text({ text: \"widget.is の CITY を見直してください。\" })\n ]\n } else {\n let data = fetchForecast(place.latitude, place.longitude)\n let cur = get(data, \"current\", {})\n let daily = get(data, \"daily\", {})\n\n let placeName = get(place, \"name\", CITY)\n let code = get(cur, \"weather_code\", -1)\n let precip = get(cur, \"precipitation\", 0)\n // current の weather_code はその時刻のモデル値で、実際に降っていても\n // 快晴〜曇り (0〜3) を返すことがある。降水量があれば雨として表示する。\n let curCode = if (precip > 0 && code >= 0 && code <= 3) 63 else code\n\n var body = [\n Ui:C:mfm({ text: `📍 {placeName}` })\n Ui:C:mfm({ text: `$[x2 {wmoIcon(curCode)}]` })\n Ui:C:mfm({ text: `$[fg.color=ffb445 $[x2 {Math:round(get(cur, \"temperature_2m\", 0))}°]] {wmoLabel(curCode)}` })\n ]\n\n let times = get(daily, \"time\", [])\n let codes = get(daily, \"weather_code\", [])\n let tmax = get(daily, \"temperature_2m_max\", [])\n let tmin = get(daily, \"temperature_2m_min\", [])\n let pprob = get(daily, \"precipitation_probability_max\", [])\n\n for (let i, times.len) {\n body = body.concat([\n Ui:C:mfm({\n text: `{wmoIcon(codes[i])} {dayLabel(i, times[i])} $[fg.color=f74e52 {Math:round(tmax[i])}°]/$[fg.color=50b5db {Math:round(tmin[i])}°] ☔{dflt(pprob[i], 0)}%`\n })\n ])\n }\n body\n }\n}\n\n@render() {\n if (firstRun) {\n Ui:render([\n Ui:C:container({\n align: \"center\"\n children: [ Ui:C:text({ text: \"天気を読み込み中… ⛅\" }) ]\n })\n ])\n }\n firstRun = false\n\n let body = weatherView()\n Ui:render([\n Ui:C:container({\n align: \"center\"\n children: body\n })\n ])\n}\n\nrender()\nAsync:interval(600000, render, false)\n"}
+{"type":"widget","data":"/// @ 1.2.1\n/// 天気予報ウィジェット\n/// Open-Meteo (https://open-meteo.com/) で現在の天気と数日分の予報を表示。\n/// 都市名から geocoding で座標を引くため API キーは不要。\n/// 元ネタ: misskey-tempura WidgetWeather (lqvp) を Open-Meteo + AiScript で再実装。\n\n// 表示する場所はここを直接書き換える (ローマ字の検索クエリ)。\n// 例: 東京→Tokyo / 札幌→Sapporo / 金沢→Kanazawa\nlet CITY = \"Kawasaki\"\n\n@dflt(v, d) {\n if (Core:type(v) == \"null\") d else v\n}\n\n@get(o, k, d) {\n if (Core:type(o) != \"obj\") {\n d\n } elif (Obj:has(o, k)) {\n dflt(o[k], d)\n } else {\n d\n }\n}\n\nvar firstRun = true\n\n/// WMO weather code を絵文字に変換\n@wmoIcon(code) {\n if (code == 0) \"☀️\"\n elif (code == 1) \"🌤️\"\n elif (code == 2) \"⛅\"\n elif (code == 3) \"☁️\"\n elif (code == 45 || code == 48) \"🌫️\"\n elif (code >= 51 && code <= 57) \"🌦️\"\n elif (code >= 61 && code <= 67) \"🌧️\"\n elif (code >= 71 && code <= 77) \"🌨️\"\n elif (code >= 80 && code <= 82) \"🌦️\"\n elif (code == 85 || code == 86) \"🌨️\"\n elif (code >= 95) \"⛈️\"\n else \"❓\"\n}\n\n/// WMO weather code を日本語ラベルに変換\n@wmoLabel(code) {\n if (code == 0) \"快晴\"\n elif (code == 1) \"晴れ\"\n elif (code == 2) \"晴れ時々曇り\"\n elif (code == 3) \"曇り\"\n elif (code == 45 || code == 48) \"霧\"\n elif (code >= 51 && code <= 57) \"霧雨\"\n elif (code >= 61 && code <= 67) \"雨\"\n elif (code >= 71 && code <= 77) \"雪\"\n elif (code >= 80 && code <= 82) \"にわか雨\"\n elif (code == 85 || code == 86) \"にわか雪\"\n elif (code >= 95) \"雷雨\"\n else \"不明\"\n}\n\n/// 日次予報の行ラベル (今日 / 明日)\n@dayLabel(i, dateStr) {\n if (i == 0) \"今日\"\n elif (i == 1) \"明日\"\n else dateStr\n}\n\nvar lastStatus = 0 // 直近の HTTP ステータス (エラー表示用。0 = 通信断、notedeck#1294)\n\n@geocode(name) {\n let url = `https://geocoding-api.open-meteo.com/v1/search?name={name}&count=1&language=ja&format=json`\n let res = Nd:http(url, { method: \"GET\" })\n lastStatus = res.status\n if (res.status != 200) { return null }\n let data = Json:parse(res.body)\n let results = get(data, \"results\", [])\n if (results.len == 0) null else results[0]\n}\n\n@fetchForecast(lat, lon) {\n let url = `https://api.open-meteo.com/v1/forecast?latitude={lat}&longitude={lon}¤t=temperature_2m,weather_code,precipitation&daily=weather_code,temperature_2m_max,temperature_2m_min,precipitation_probability_max&timezone=auto&forecast_days=2`\n let res = Nd:http(url, { method: \"GET\" })\n lastStatus = res.status\n if (res.status != 200) { return null }\n Json:parse(res.body)\n}\n\n@fetchError() {\n let reason = if (lastStatus == 0) { \"通信できません\" } else { `HTTP {lastStatus}` }\n [ Ui:C:mfm({ text: `$[fg.color=f74e52 ⚠️ 天気を取得できません ({reason})]` }) ]\n}\n\n@weatherView() {\n let place = geocode(CITY)\n if (lastStatus != 200) { return fetchError() }\n if (Core:type(place) == \"null\") {\n [\n Ui:C:mfm({ text: `$[fg.color=f74e52 ⚠️ 「{CITY}」が見つかりませんでした]` })\n Ui:C:text({ text: \"widget.is の CITY を見直してください。\" })\n ]\n } else {\n let data = fetchForecast(place.latitude, place.longitude)\n if (Core:type(data) == \"null\") { return fetchError() }\n let cur = get(data, \"current\", {})\n let daily = get(data, \"daily\", {})\n\n let placeName = get(place, \"name\", CITY)\n let code = get(cur, \"weather_code\", -1)\n let precip = get(cur, \"precipitation\", 0)\n // current の weather_code はその時刻のモデル値で、実際に降っていても\n // 快晴〜曇り (0〜3) を返すことがある。降水量があれば雨として表示する。\n let curCode = if (precip > 0 && code >= 0 && code <= 3) 63 else code\n\n var body = [\n Ui:C:mfm({ text: `📍 {placeName}` })\n Ui:C:mfm({ text: `$[x2 {wmoIcon(curCode)}]` })\n Ui:C:mfm({ text: `$[fg.color=ffb445 $[x2 {Math:round(get(cur, \"temperature_2m\", 0))}°]] {wmoLabel(curCode)}` })\n ]\n\n let times = get(daily, \"time\", [])\n let codes = get(daily, \"weather_code\", [])\n let tmax = get(daily, \"temperature_2m_max\", [])\n let tmin = get(daily, \"temperature_2m_min\", [])\n let pprob = get(daily, \"precipitation_probability_max\", [])\n\n for (let i, times.len) {\n body = body.concat([\n Ui:C:mfm({\n text: `{wmoIcon(codes[i])} {dayLabel(i, times[i])} $[fg.color=f74e52 {Math:round(tmax[i])}°]/$[fg.color=50b5db {Math:round(tmin[i])}°] ☔{dflt(pprob[i], 0)}%`\n })\n ])\n }\n body\n }\n}\n\n@render() {\n if (firstRun) {\n Ui:render([\n Ui:C:container({\n align: \"center\"\n children: [ Ui:C:text({ text: \"天気を読み込み中… ⛅\" }) ]\n })\n ])\n }\n firstRun = false\n\n let body = weatherView()\n Ui:render([\n Ui:C:container({\n align: \"center\"\n children: body\n })\n ])\n}\n\nrender()\nAsync:interval(600000, render, false)\n"}
diff --git a/public/registry/widgets/weather/widget.is b/public/registry/widgets/weather/widget.is
index 1fdb5b0..07798a7 100644
--- a/public/registry/widgets/weather/widget.is
+++ b/public/registry/widgets/weather/widget.is
@@ -63,9 +63,13 @@ var firstRun = true
else dateStr
}
+var lastStatus = 0 // 直近の HTTP ステータス (エラー表示用。0 = 通信断、notedeck#1294)
+
@geocode(name) {
let url = `https://geocoding-api.open-meteo.com/v1/search?name={name}&count=1&language=ja&format=json`
let res = Nd:http(url, { method: "GET" })
+ lastStatus = res.status
+ if (res.status != 200) { return null }
let data = Json:parse(res.body)
let results = get(data, "results", [])
if (results.len == 0) null else results[0]
@@ -74,11 +78,19 @@ var firstRun = true
@fetchForecast(lat, lon) {
let url = `https://api.open-meteo.com/v1/forecast?latitude={lat}&longitude={lon}¤t=temperature_2m,weather_code,precipitation&daily=weather_code,temperature_2m_max,temperature_2m_min,precipitation_probability_max&timezone=auto&forecast_days=2`
let res = Nd:http(url, { method: "GET" })
+ lastStatus = res.status
+ if (res.status != 200) { return null }
Json:parse(res.body)
}
+@fetchError() {
+ let reason = if (lastStatus == 0) { "通信できません" } else { `HTTP {lastStatus}` }
+ [ Ui:C:mfm({ text: `$[fg.color=f74e52 ⚠️ 天気を取得できません ({reason})]` }) ]
+}
+
@weatherView() {
let place = geocode(CITY)
+ if (lastStatus != 200) { return fetchError() }
if (Core:type(place) == "null") {
[
Ui:C:mfm({ text: `$[fg.color=f74e52 ⚠️ 「{CITY}」が見つかりませんでした]` })
@@ -86,6 +98,7 @@ var firstRun = true
]
} else {
let data = fetchForecast(place.latitude, place.longitude)
+ if (Core:type(data) == "null") { return fetchError() }
let cur = get(data, "current", {})
let daily = get(data, "daily", {})
diff --git a/public/registry/widgets/zenn/api.json b/public/registry/widgets/zenn/api.json
index 11b940d..0a12434 100644
--- a/public/registry/widgets/zenn/api.json
+++ b/public/registry/widgets/zenn/api.json
@@ -1 +1 @@
-{"type":"widget","data":"/// @ 1.2.1\n/// Zenn トレンドウィジェット\n/// Zenn の公開 API からトレンド記事 (人気順) を著者付きで表示。API キー不要。\n/// 特定ユーザーではなく Zenn 全体の「今読まれている記事」にフォーカスする。\n/// 設計の元ネタ: ctftime / atcoder ウィジェット (イベント/ランキング表示ループ)。\n\n// 表示する記事件数 (1 位 + 以降)。ここを書き換えると増減できる。\nlet COUNT = 8\n\n// 集計期間: \"daily\" (今日) / \"weekly\" (今週) / \"monthly\" (今月)\nlet PERIOD = \"daily\"\n\n// Zenn のブランドカラー・各種テキスト色\nlet ACCENT = \"3ea8ff\"\nlet MUTED = \"8a96a3\"\nlet LIKE = \"f43f5e\"\nlet LINE = \"5a6573\"\n\n@dflt(v, d) {\n if (Core:type(v) == \"null\") d else v\n}\n\n@get(o, k, d) {\n if (Core:type(o) != \"obj\") {\n d\n } elif (Obj:has(o, k)) {\n dflt(o[k], d)\n } else {\n d\n }\n}\n\nvar firstRun = true\n\n/// 大きな数を \"1.4k\" のように丸める (いいね数向け)\n@fmtNum(n) {\n if (Core:type(n) != \"num\") {\n \"0\"\n } elif (n >= 1000) {\n `{Math:floor(n / 100) / 10}k`\n } else {\n `{n}`\n }\n}\n\n/// 期間ラベル\n@periodLabel(p) {\n if (p == \"daily\") \"今日\"\n elif (p == \"weekly\") \"今週\"\n elif (p == \"monthly\") \"今月\"\n else p\n}\n\n/// 背景色付きラベルバッジ (MFM)\n@badge(text, color) {\n `$[bg.color={color} $[fg.color=ffffff {text} ]]`\n}\n\n/// 記事タイプ (tech / idea) のバッジ\n@typeBadge(t) {\n if (t == \"tech\") badge(\"💻 Tech\", ACCENT)\n elif (t == \"idea\") badge(\"💡 Idea\", \"f59e0b\")\n else `$[fg.color={MUTED} {t}]`\n}\n\n/// 著者名を取り出す\n@authorName(a) {\n get(get(a, \"user\", {}), \"username\", \"\")\n}\n\n/// 横区切り線\n@divider() {\n Ui:C:mfm({ text: `$[fg.color={LINE} ───────────────]` })\n}\n\n/// ヘッダー (ブランド + 期間)\n@header() {\n Ui:C:mfm({\n text: `$[fg.color={ACCENT} **Zenn**] $[fg.color={MUTED} トレンド ・ {periodLabel(PERIOD)}]`\n })\n}\n\n@fetchArticles() {\n let url = `https://zenn.dev/api/articles?order={PERIOD}&count={COUNT}`\n let res = Nd:http(url, {\n method: \"GET\"\n headers: {\n \"User-Agent\": \"notedeck-zenn-widget\"\n \"Accept\": \"application/json\"\n }\n })\n if (res.status != 200) {\n { error: `取得失敗 (HTTP {res.status})` }\n } else {\n let parsed = Json:parse(res.body)\n { articles: get(parsed, \"articles\", []) }\n }\n}\n\n/// 2 位以降の記事を 2 段 (2 要素) のリスト項目にする\n/// MFM は
を展開しないので行ごとに別要素へ、リンクは fn の外に置く\n@articleRow(a, rank) {\n let emoji = get(a, \"emoji\", \"📄\")\n let title = get(a, \"title\", \"(無題)\")\n let url = `https://zenn.dev{get(a, \"path\", \"\")}`\n let likes = fmtNum(get(a, \"liked_count\", 0))\n let uname = authorName(a)\n [\n Ui:C:mfm({ text: `$[fg.color={MUTED} #{rank}] {emoji} [{title}]({url})` })\n Ui:C:mfm({ text: ` $[fg.color={LIKE} ♥{likes}] $[fg.color={MUTED} ・ by] [@{uname}](https://zenn.dev/{uname})` })\n ]\n}\n\n@articlesView() {\n let data = fetchArticles()\n\n // 取得失敗\n if (Core:type(get(data, \"error\", null)) != \"null\") {\n return [\n header()\n Ui:C:mfm({ text: `$[fg.color=f74e52 ⚠️ {data.error}]` })\n ]\n }\n\n let articles = get(data, \"articles\", [])\n if (Core:type(articles) != \"arr\" || articles.len == 0) {\n return [\n header()\n Ui:C:mfm({ text: `$[fg.color={MUTED} 📭 トレンド記事が取得できません]` })\n ]\n }\n\n // 1 位 (主役カード)\n let head = articles[0]\n let emoji = get(head, \"emoji\", \"📄\")\n let title = get(head, \"title\", \"(無題)\")\n let url = `https://zenn.dev{get(head, \"path\", \"\")}`\n let likes = fmtNum(get(head, \"liked_count\", 0))\n let comments = get(head, \"comments_count\", 0)\n let uname = authorName(head)\n\n var body = [\n header()\n divider()\n Ui:C:mfm({ text: `$[fg.color={MUTED} 🥇 TREND #1]` })\n Ui:C:mfm({ text: `$[x2 {emoji}]` })\n Ui:C:mfm({ text: `[**{title}**]({url})` })\n Ui:C:mfm({ text: `{typeBadge(get(head, \"article_type\", \"\"))} $[fg.color={LIKE} ♥ {likes}] $[fg.color={MUTED} 💬 {comments}]` })\n Ui:C:mfm({ text: `$[fg.color={MUTED} by] [@{uname}](https://zenn.dev/{uname})` })\n ]\n\n // 2 位以降\n if (articles.len > 1) {\n body = body.concat([\n divider()\n Ui:C:mfm({ text: `$[fg.color={MUTED} 📈 ランキング]` })\n ])\n for (let i, articles.len - 1) {\n body = body.concat(articleRow(articles[i + 1], i + 2))\n }\n }\n\n // フッター導線\n body.concat([\n divider()\n Ui:C:mfm({ text: `[Zenn のトレンドを見る →](https://zenn.dev/)` })\n ])\n}\n\n@render() {\n if (firstRun) {\n Ui:render([\n Ui:C:container({\n align: \"center\"\n children: [ Ui:C:text({ text: \"Zenn のトレンドを読み込み中… 📘\" }) ]\n })\n ])\n }\n firstRun = false\n\n let body = articlesView()\n Ui:render([\n Ui:C:container({\n children: body\n })\n ])\n}\n\nrender()\n// トレンドは頻繁に変わらないので 15 分間隔で更新\nAsync:interval(900000, render, false)\n"}
+{"type":"widget","data":"/// @ 1.2.1\n/// Zenn トレンドウィジェット\n/// Zenn の公開 API からトレンド記事 (人気順) を著者付きで表示。API キー不要。\n/// 特定ユーザーではなく Zenn 全体の「今読まれている記事」にフォーカスする。\n/// 設計の元ネタ: ctftime / atcoder ウィジェット (イベント/ランキング表示ループ)。\n\n// 表示する記事件数 (1 位 + 以降)。ここを書き換えると増減できる。\nlet COUNT = 8\n\n// 集計期間: \"daily\" (今日) / \"weekly\" (今週) / \"monthly\" (今月)\nlet PERIOD = \"daily\"\n\n// Zenn のブランドカラー・各種テキスト色\nlet ACCENT = \"3ea8ff\"\nlet MUTED = \"8a96a3\"\nlet LIKE = \"f43f5e\"\nlet LINE = \"5a6573\"\n\n@dflt(v, d) {\n if (Core:type(v) == \"null\") d else v\n}\n\n@get(o, k, d) {\n if (Core:type(o) != \"obj\") {\n d\n } elif (Obj:has(o, k)) {\n dflt(o[k], d)\n } else {\n d\n }\n}\n\nvar firstRun = true\n\n/// 大きな数を \"1.4k\" のように丸める (いいね数向け)\n@fmtNum(n) {\n if (Core:type(n) != \"num\") {\n \"0\"\n } elif (n >= 1000) {\n `{Math:floor(n / 100) / 10}k`\n } else {\n `{n}`\n }\n}\n\n/// 期間ラベル\n@periodLabel(p) {\n if (p == \"daily\") \"今日\"\n elif (p == \"weekly\") \"今週\"\n elif (p == \"monthly\") \"今月\"\n else p\n}\n\n/// 背景色付きラベルバッジ (MFM)\n@badge(text, color) {\n `$[bg.color={color} $[fg.color=ffffff {text} ]]`\n}\n\n/// 記事タイプ (tech / idea) のバッジ\n@typeBadge(t) {\n if (t == \"tech\") badge(\"💻 Tech\", ACCENT)\n elif (t == \"idea\") badge(\"💡 Idea\", \"f59e0b\")\n else `$[fg.color={MUTED} {t}]`\n}\n\n/// 著者名を取り出す\n@authorName(a) {\n get(get(a, \"user\", {}), \"username\", \"\")\n}\n\n/// 横区切り線\n@divider() {\n Ui:C:mfm({ text: `$[fg.color={LINE} ───────────────]` })\n}\n\n/// ヘッダー (ブランド + 期間)\n@header() {\n Ui:C:mfm({\n text: `$[fg.color={ACCENT} **Zenn**] $[fg.color={MUTED} トレンド ・ {periodLabel(PERIOD)}]`\n })\n}\n\n@fetchArticles() {\n let url = `https://zenn.dev/api/articles?order={PERIOD}&count={COUNT}`\n let res = Nd:http(url, {\n method: \"GET\"\n headers: {\n \"User-Agent\": \"notedeck-zenn-widget\"\n \"Accept\": \"application/json\"\n }\n })\n if (res.status == 0) {\n // 通信断 (DNS 失敗・接続拒否・タイムアウト)。NoteDeck は throw せず status 0 で返す (notedeck#1294)\n { error: \"通信できません\" }\n } elif (res.status != 200) {\n { error: `取得失敗 (HTTP {res.status})` }\n } else {\n let parsed = Json:parse(res.body)\n { articles: get(parsed, \"articles\", []) }\n }\n}\n\n/// 2 位以降の記事を 2 段 (2 要素) のリスト項目にする\n/// MFM は
を展開しないので行ごとに別要素へ、リンクは fn の外に置く\n@articleRow(a, rank) {\n let emoji = get(a, \"emoji\", \"📄\")\n let title = get(a, \"title\", \"(無題)\")\n let url = `https://zenn.dev{get(a, \"path\", \"\")}`\n let likes = fmtNum(get(a, \"liked_count\", 0))\n let uname = authorName(a)\n [\n Ui:C:mfm({ text: `$[fg.color={MUTED} #{rank}] {emoji} [{title}]({url})` })\n Ui:C:mfm({ text: ` $[fg.color={LIKE} ♥{likes}] $[fg.color={MUTED} ・ by] [@{uname}](https://zenn.dev/{uname})` })\n ]\n}\n\n@articlesView() {\n let data = fetchArticles()\n\n // 取得失敗\n if (Core:type(get(data, \"error\", null)) != \"null\") {\n return [\n header()\n Ui:C:mfm({ text: `$[fg.color=f74e52 ⚠️ {data.error}]` })\n ]\n }\n\n let articles = get(data, \"articles\", [])\n if (Core:type(articles) != \"arr\" || articles.len == 0) {\n return [\n header()\n Ui:C:mfm({ text: `$[fg.color={MUTED} 📭 トレンド記事が取得できません]` })\n ]\n }\n\n // 1 位 (主役カード)\n let head = articles[0]\n let emoji = get(head, \"emoji\", \"📄\")\n let title = get(head, \"title\", \"(無題)\")\n let url = `https://zenn.dev{get(head, \"path\", \"\")}`\n let likes = fmtNum(get(head, \"liked_count\", 0))\n let comments = get(head, \"comments_count\", 0)\n let uname = authorName(head)\n\n var body = [\n header()\n divider()\n Ui:C:mfm({ text: `$[fg.color={MUTED} 🥇 TREND #1]` })\n Ui:C:mfm({ text: `$[x2 {emoji}]` })\n Ui:C:mfm({ text: `[**{title}**]({url})` })\n Ui:C:mfm({ text: `{typeBadge(get(head, \"article_type\", \"\"))} $[fg.color={LIKE} ♥ {likes}] $[fg.color={MUTED} 💬 {comments}]` })\n Ui:C:mfm({ text: `$[fg.color={MUTED} by] [@{uname}](https://zenn.dev/{uname})` })\n ]\n\n // 2 位以降\n if (articles.len > 1) {\n body = body.concat([\n divider()\n Ui:C:mfm({ text: `$[fg.color={MUTED} 📈 ランキング]` })\n ])\n for (let i, articles.len - 1) {\n body = body.concat(articleRow(articles[i + 1], i + 2))\n }\n }\n\n // フッター導線\n body.concat([\n divider()\n Ui:C:mfm({ text: `[Zenn のトレンドを見る →](https://zenn.dev/)` })\n ])\n}\n\n@render() {\n if (firstRun) {\n Ui:render([\n Ui:C:container({\n align: \"center\"\n children: [ Ui:C:text({ text: \"Zenn のトレンドを読み込み中… 📘\" }) ]\n })\n ])\n }\n firstRun = false\n\n let body = articlesView()\n Ui:render([\n Ui:C:container({\n children: body\n })\n ])\n}\n\nrender()\n// トレンドは頻繁に変わらないので 15 分間隔で更新\nAsync:interval(900000, render, false)\n"}
diff --git a/public/registry/widgets/zenn/widget.is b/public/registry/widgets/zenn/widget.is
index 99d5d26..9b7270b 100644
--- a/public/registry/widgets/zenn/widget.is
+++ b/public/registry/widgets/zenn/widget.is
@@ -89,7 +89,10 @@ var firstRun = true
"Accept": "application/json"
}
})
- if (res.status != 200) {
+ if (res.status == 0) {
+ // 通信断 (DNS 失敗・接続拒否・タイムアウト)。NoteDeck は throw せず status 0 で返す (notedeck#1294)
+ { error: "通信できません" }
+ } elif (res.status != 200) {
{ error: `取得失敗 (HTTP {res.status})` }
} else {
let parsed = Json:parse(res.body)