@oniisan/htvoffcial 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,571 @@
1
+ /*
2
+ Copyright (C) 2026 htvoffcial
3
+ SPDX-License-Identifier: SSPL-1.0
4
+
5
+ This program is free software: you can use, redistribute,
6
+ and/or modify it under the terms of the Server Side
7
+ Public License, version 1, as published by MongoDB, Inc.
8
+ */
9
+ /*
10
+ - 「昨日のまとめ」に対する優しいツッコミ,任意で!,タイトルと本文の齟齬くらいは無視!(コメディアン寄り)
11
+ */
12
+ import fs from "node:fs";
13
+
14
+ const GH_TOKEN = process.env.GH_TOKEN;
15
+ const CF_ACCOUNT_ID = process.env.CF_ACCOUNT_ID;
16
+ const CF_API_TOKEN = process.env.CF_API_TOKEN;
17
+
18
+ if (!GH_TOKEN) throw new Error("GH_TOKEN is missing");
19
+ if (!CF_ACCOUNT_ID) throw new Error("CF_ACCOUNT_ID is missing");
20
+ if (!CF_API_TOKEN) throw new Error("CF_API_TOKEN is missing");
21
+
22
+ const OWNER = process.env.GITHUB_REPOSITORY_OWNER;
23
+ const REPO = (process.env.GITHUB_REPOSITORY || "").split("/")[1];
24
+
25
+ if (!OWNER || !REPO) throw new Error("Missing GITHUB_REPOSITORY(_OWNER) env");
26
+
27
+ const CF_MODEL = "@cf/google/gemma-4-26b-a4b-it";
28
+
29
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
30
+ // 気象庁アメダス(bosai)簡易ユーティリティ
31
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
32
+
33
+ /**
34
+ * 気象庁アメダス bosai の point データは 3時間ごとのファイルに分割されている。
35
+ * 例) YYYYMMDD_00,03,06,...,21
36
+ * @param {string} yyyymmdd
37
+ * @returns {string[]}
38
+ */
39
+ function amedasH3List(yyyymmdd) {
40
+ return ["00", "03", "06", "09", "12", "15", "18", "21"].map((hh) => `${yyyymmdd}_${hh}`);
41
+ }
42
+
43
+ /**
44
+ * @param {string} s
45
+ * @returns {string}
46
+ */
47
+ function pad2(s) {
48
+ return String(s).padStart(2, "0");
49
+ }
50
+
51
+ /**
52
+ * JST日付(YYYY-MM-DD) → 気象庁アメダス用(YYYYMMDD)
53
+ * @param {string} dayJst
54
+ */
55
+ function toYyyymmdd(dayJst) {
56
+ return dayJst.replaceAll("-", "");
57
+ }
58
+
59
+ /**
60
+ * ざっくり「代表天気」を作る。
61
+ * アメダスは WMO weathercode を返さないので、
62
+ * 日中(6-18時)の1時間降水量の有無で晴/雨を判定し、
63
+ * 欠測が多い場合は unknown。
64
+ *
65
+ * @param {{hour:number, precipitation1hMm:number|null}[]} hourly
66
+ * @returns {{code:number, label:string, icon:string, category:string, isSunny:boolean, isRainy:boolean, isCloudy:boolean, isSnowy:boolean, isStormy:boolean}}
67
+ */
68
+ function dominantWeatherFromAmedas(hourly) {
69
+ const daytime = hourly.filter((x) => x.hour >= 6 && x.hour <= 18);
70
+
71
+ const valid = daytime.filter((x) => typeof x.precipitation1hMm === "number");
72
+ const rainy = valid.filter((x) => (x.precipitation1hMm ?? 0) > 0);
73
+
74
+ // ベストエフォート判定
75
+ // - validが1つでもあり、雨が1回でもあれば「雨」
76
+ // - validがある程度(>=3)あり、雨が0なら「晴」
77
+ // - それ以外は「不明」
78
+ if (valid.length < 6) {
79
+ if (rainy.length >= 1) {
80
+ return {
81
+ code: 61,
82
+ label: "雨(アメダス判定・簡易)",
83
+ icon: "🌧️",
84
+ category: "rain",
85
+ isSunny: false,
86
+ isRainy: true,
87
+ isCloudy: false,
88
+ isSnowy: false,
89
+ isStormy: false,
90
+ };
91
+ }
92
+
93
+ if (valid.length >= 3) {
94
+ return {
95
+ code: 0,
96
+ label: "晴(アメダス判定・簡易)",
97
+ icon: "☀️",
98
+ category: "sunny",
99
+ isSunny: true,
100
+ isRainy: false,
101
+ isCloudy: false,
102
+ isSnowy: false,
103
+ isStormy: false,
104
+ };
105
+ }
106
+
107
+ return {
108
+ code: -1,
109
+ label: "不明",
110
+ icon: "❓",
111
+ category: "unknown",
112
+ isSunny: false,
113
+ isRainy: false,
114
+ isCloudy: false,
115
+ isSnowy: false,
116
+ isStormy: false,
117
+ };
118
+ }
119
+
120
+ // データが十分ある場合は従来通り
121
+ if (rainy.length >= 1) {
122
+ return {
123
+ code: 61,
124
+ label: "雨(アメダス判定)",
125
+ icon: "🌧️",
126
+ category: "rain",
127
+ isSunny: false,
128
+ isRainy: true,
129
+ isCloudy: false,
130
+ isSnowy: false,
131
+ isStormy: false,
132
+ };
133
+ }
134
+
135
+ return {
136
+ code: 0,
137
+ label: "晴(アメダス判定)",
138
+ icon: "☀️",
139
+ category: "sunny",
140
+ isSunny: true,
141
+ isRainy: false,
142
+ isCloudy: false,
143
+ isSnowy: false,
144
+ isSnowy: false,
145
+ isStormy: false,
146
+ };
147
+ }
148
+
149
+ /**
150
+ * AQC付きの値配列から数値を取り出す。
151
+ * bosai/amedas の多くの要素は [value, aqc] 形式。
152
+ *
153
+ * ただしフィールド/時期によっては「直値(number)」で返ることがある。
154
+ * 例: precipitation1h: 0
155
+ *
156
+ * @param {unknown} v
157
+ * @returns {number|null}
158
+ */
159
+ function readAmedasNumber(v) {
160
+ if (typeof v === "number") return v;
161
+
162
+ if (!Array.isArray(v)) return null;
163
+ const value = v[0];
164
+ const aqc = v[1];
165
+
166
+ if (value == null) return null;
167
+ if (typeof value !== "number") return null;
168
+
169
+ if (typeof aqc === "number" && aqc !== 0) return null;
170
+
171
+ return value;
172
+ }
173
+
174
+ /**
175
+ * 気象庁 bosai アメダス から、指定日の時系列(10分刻み)を取得し、1時間降水量ベースの簡易サマリーを返す。
176
+ *
177
+ * @param {{amedasPoint:string, dayJst:string}} params
178
+ * @returns {Promise<{dominantWeather: ReturnType<typeof dominantWeatherFromAmedas>, rainyHoursCount:number}>}
179
+ */
180
+ async function getAmedasDominantWeatherForDay({ amedasPoint, dayJst }) {
181
+ const yyyymmdd = toYyyymmdd(dayJst);
182
+
183
+ const base = "https://www.jma.go.jp/bosai/amedas/data/point";
184
+
185
+ // 3時間ごとのJSONを全部取ってマージする
186
+ const urls = amedasH3List(yyyymmdd).map((h3) => `${base}/${amedasPoint}/${h3}.json`);
187
+
188
+ /** @type {Record<string, any>} */
189
+ const merged = {};
190
+ let okCount = 0;
191
+ let skipCount = 0;
192
+
193
+ for (const url of urls) {
194
+ const res = await fetch(url);
195
+
196
+ if (!res.ok) {
197
+ console.warn(`AMeDAS fetch skipped: ${res.status} ${url}`);
198
+ skipCount++;
199
+ continue;
200
+ }
201
+ okCount++;
202
+
203
+ const json = await res.json().catch(() => null);
204
+ if (!json || typeof json !== "object") continue;
205
+
206
+ for (const [t, v] of Object.entries(json)) merged[t] = v;
207
+ }
208
+
209
+ console.log(
210
+ `AMeDAS fetch summary: ok=${okCount}/${urls.length} skipped=${skipCount} mergedKeys=${Object.keys(merged).length}`
211
+ );
212
+
213
+ /** @type {Map<number, number|null>} */
214
+ const precipByHour = new Map();
215
+
216
+ for (const t of Object.keys(merged).sort()) {
217
+ // 気象庁のpointデータのキーは "YYYYMMDDHHMMSS" (例: "20260523090000") の形式
218
+ let hourJst;
219
+ if (/^\d{14}$/.test(t)) {
220
+ // 8文字目から2文字分(時間)を取得
221
+ hourJst = Number(t.substring(8, 10));
222
+ } else {
223
+ // 万が一将来フォーマットが変わったときのフォールバック用
224
+ const dt = new Date(t);
225
+ const m = t.match(/T(\d{2}):/);
226
+ hourJst = m ? Number(m[1]) : dt.getUTCHours();
227
+ }
228
+
229
+ const p1h = readAmedasNumber(merged[t]?.precipitation1h);
230
+
231
+ if (typeof p1h === "number") {
232
+ precipByHour.set(hourJst, p1h);
233
+ } else {
234
+ if (!precipByHour.has(hourJst)) precipByHour.set(hourJst, null);
235
+ }
236
+ }
237
+
238
+ const hourly = [];
239
+ for (let h = 0; h < 24; h++) {
240
+ hourly.push({
241
+ hour: h,
242
+ precipitation1hMm: precipByHour.has(h) ? precipByHour.get(h) : null,
243
+ });
244
+ }
245
+
246
+ const daytime = hourly.filter((x) => x.hour >= 6 && x.hour <= 18);
247
+ const daytimeValid = daytime.filter((x) => typeof x.precipitation1hMm === "number").length;
248
+ console.log(`AMeDAS daytime valid points: ${daytimeValid}/13`);
249
+
250
+ const dominantWeather = dominantWeatherFromAmedas(hourly);
251
+
252
+ const rainyHoursCount = hourly
253
+ .filter((x) => x.hour >= 6 && x.hour <= 18)
254
+ .filter((x) => (x.precipitation1hMm ?? 0) > 0).length;
255
+
256
+ return { dominantWeather, rainyHoursCount };
257
+ }
258
+
259
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
260
+
261
+ // JSTの「昨日」範囲をUTCに変換
262
+ function getYesterdayJstRangeUtc() {
263
+ const now = new Date();
264
+ const nowJst = new Date(now.getTime() + 9 * 60 * 60 * 1000);
265
+ const y = new Date(Date.UTC(nowJst.getUTCFullYear(), nowJst.getUTCMonth(), nowJst.getUTCDate() - 1));
266
+ const yyyy = y.getUTCFullYear();
267
+ const mm = String(y.getUTCMonth() + 1).padStart(2, "0");
268
+ const dd = String(y.getUTCDate()).padStart(2, "0");
269
+ const dayJst = `${yyyy}-${mm}-${dd}`;
270
+
271
+ const startUtc = new Date(`${dayJst}T00:00:00+09:00`).toISOString().replace(/\.\d{3}Z$/, "Z");
272
+ const endUtc = new Date(`${dayJst}T23:59:59+09:00`).toISOString().replace(/\.\d{3}Z$/, "Z");
273
+
274
+ return { dayJst, startUtc, endUtc };
275
+ }
276
+
277
+ async function safeFetchText(url, { timeoutMs = 10000, retries = 2 } = {}) {
278
+ let lastErr;
279
+
280
+ for (let attempt = 0; attempt <= retries; attempt++) {
281
+ const controller = new AbortController();
282
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
283
+
284
+ try {
285
+ const res = await fetch(url, {
286
+ method: "GET",
287
+ signal: controller.signal,
288
+ });
289
+
290
+ const text = await res.text();
291
+ return { res, text };
292
+ } catch (e) {
293
+ lastErr = e;
294
+
295
+ if (attempt < retries) {
296
+ const backoffMs = 500 * (attempt + 1);
297
+ await new Promise((r) => setTimeout(r, backoffMs));
298
+ }
299
+ } finally {
300
+ clearTimeout(timer);
301
+ }
302
+ }
303
+
304
+ throw lastErr;
305
+ }
306
+
307
+ async function getNawatobi(dayJst) {
308
+ try {
309
+ const { res, text: csvText } = await safeFetchText("https://harutv.stars.ne.jp/jumprope/count.csv", {
310
+ timeoutMs: 12000,
311
+ retries: 2,
312
+ });
313
+
314
+ if (!res.ok) {
315
+ throw new Error(`NawatobiGetError error: ${res.status} ${csvText}`);
316
+ }
317
+
318
+ const data = {};
319
+ const lines = csvText.split(/\r?\n/);
320
+ for (const line of lines) {
321
+ const [key, ...valueParts] = line.split(":");
322
+ if (key && valueParts.length > 0) {
323
+ data[key.trim()] = valueParts.join(":").trim();
324
+ }
325
+ }
326
+
327
+ if (!data.lastmodified || !data.count) {
328
+ throw new Error("CSVのデータ形式が不正です。");
329
+ }
330
+
331
+ // 1. CSVの "2026/07/04 18:03(+09:00)" から日付部分(スペースより前)だけを切り出す
332
+ const csvDatePart = data.lastmodified.split(" ")[0]; // -> "2026/07/04"
333
+
334
+ // 2. スラッシュをハイフンに変換して形式を統一する
335
+ const csvDateJst = csvDatePart.replaceAll("/", "-"); // -> "2026-07-04"
336
+
337
+ // 3. 対象日(dayJst)とCSVの更新日が完全に一致するか判定する
338
+ if (csvDateJst === dayJst) {
339
+ return data.count + "回";
340
+ } else {
341
+ return "お休み(0回)";
342
+ }
343
+ } catch (e) {
344
+ console.warn(`Nawatobi fetch/parse failed. fallback=お休み(0回) reason=${String(e?.message || e)}`);
345
+ return "お休み(0回)";
346
+ }
347
+ }
348
+ async function ghGraphql(query, variables) {
349
+ const res = await fetch("https://api.github.com/graphql", {
350
+ method: "POST",
351
+ headers: {
352
+ Authorization: `bearer ${GH_TOKEN}`,
353
+ "Content-Type": "application/json",
354
+ },
355
+ body: JSON.stringify({ query, variables }),
356
+ });
357
+
358
+ if (!res.ok) {
359
+ const text = await res.text();
360
+ throw new Error(`GitHub GraphQL error: ${res.status} ${text}`);
361
+ }
362
+ return res.json();
363
+ }
364
+
365
+ function clampText(s, maxChars) {
366
+ if (!s) return "";
367
+ const t = s.replace(/\r\n/g, "\n").trim();
368
+ if (t.length <= maxChars) return t;
369
+ return t.slice(0, maxChars) + "…(省略)";
370
+ }
371
+
372
+ // トークン(コスト)節約:本文をカットして合計も制限
373
+ function buildPromptSource(discussions, { maxPerBodyChars = 800, maxTotalChars = 3500 } = {}) {
374
+ let total = 0;
375
+ const lines = [];
376
+
377
+ for (const d of discussions) {
378
+ const body = clampText(d.bodyText || "", maxPerBodyChars);
379
+ const chunk = `【${d.title}】
380
+ URL: ${d.url}
381
+ 本文(抜粋):
382
+ ${body}
383
+ `;
384
+ if (total + chunk.length > maxTotalChars) break;
385
+ lines.push(chunk);
386
+ total += chunk.length;
387
+ }
388
+
389
+ return { source: lines.join("\n"), usedChars: total };
390
+ }
391
+
392
+ async function cfAiChat({ accountId, apiToken, model, messages, temperature}) {
393
+ const url = `https://api.cloudflare.com/client/v4/accounts/${accountId}/ai/run/${model}`;
394
+
395
+ const res = await fetch(url, {
396
+ method: "POST",
397
+ headers: {
398
+ Authorization: `Bearer ${apiToken}`,
399
+ "Content-Type": "application/json",
400
+ },
401
+ body: JSON.stringify({ messages, temperature }),
402
+ });
403
+
404
+ const json = await res.json().catch(() => null);
405
+
406
+ if (!res.ok || json?.success === false) {
407
+ const err = JSON.stringify(json ?? { status: res.status }, null, 2);
408
+ throw new Error(`Cloudflare AI error: ${res.status} ${err}`);
409
+ }
410
+
411
+ const text =
412
+ json?.result?.response ??
413
+ json?.result?.output_text ??
414
+ json?.result?.text ??
415
+ json?.result?.choices?.[0]?.message?.content ??
416
+ json?.result?.choices?.[0]?.text ??
417
+ json?.choices?.[0]?.message?.content ??
418
+ json?.choices?.[0]?.text ??
419
+ null;
420
+
421
+ if (!text) {
422
+ throw new Error(`Cloudflare AI: unknown response shape: ${JSON.stringify(json).slice(0, 800)}`);
423
+ }
424
+ return String(text).trim();
425
+ }
426
+
427
+ function replaceBlock(readme, newBlock) {
428
+ const start = "<!-- DISCUSS_COACH_START -->";
429
+ const end = "<!-- DISCUSS_COACH_END -->";
430
+ const s = readme.indexOf(start);
431
+ const e = readme.indexOf(end);
432
+ if (s === -1 || e === -1 || e < s) {
433
+ throw new Error("README.md does not contain DISCUSS_COACH_START/END markers");
434
+ }
435
+
436
+ const before = readme.slice(0, s + start.length);
437
+ const after = readme.slice(e);
438
+
439
+ // Always overwrite content between markers (idempotent)
440
+ return `${before}\n${newBlock.trim()}\n\n${after}`;
441
+ }
442
+
443
+ function buildReadmeBlock({ dayJst, text, dominantWeather }) {
444
+ const header = `## Discussまとめ(体操のお兄さん)
445
+ **対象日(JST):** ${dayJst}
446
+ `;
447
+ return `${header}\n${text}\n`;
448
+ }
449
+
450
+ async function main() {
451
+ const { dayJst, startUtc, endUtc } = getYesterdayJstRangeUtc();
452
+
453
+ // ── 天気(アメダス) ─────────────────────────
454
+ // NOTE: 松戸市に近いアメダス地点コードを固定で指定。
455
+ // もし地点を変えたい場合は、気象庁アメダス画面の amdno=xxxxx を参照。
456
+ // https://www.jma.go.jp/bosai/amedas/
457
+ const AMEDAS_POINT = process.env.AMEDAS_POINT || "44132";
458
+
459
+ const { dominantWeather, rainyHoursCount } = await getAmedasDominantWeatherForDay({
460
+ amedasPoint: AMEDAS_POINT,
461
+ dayJst,
462
+ });
463
+ const yesterdayjumpcount = await getNawatobi(dayJst);
464
+ console.log(`天気サマリー(AMeDAS): ${dominantWeather.icon} ${dominantWeather.label} (雨: ${rainyHoursCount}h)`);
465
+
466
+ const query = `
467
+ query($owner:String!, $repo:String!, $after:String) {
468
+ repository(owner:$owner, name:$repo) {
469
+ discussions(first:50, after:$after, orderBy:{field:CREATED_AT, direction:DESC}) {
470
+ pageInfo { hasNextPage endCursor }
471
+ nodes { title url createdAt bodyText }
472
+ }
473
+ }
474
+ }
475
+ `;
476
+
477
+ const nodes = [];
478
+ let after = null;
479
+
480
+ while (true) {
481
+ const data = await ghGraphql(query, { owner: OWNER, repo: REPO, after });
482
+ const page = data?.data?.repository?.discussions;
483
+ const list = page?.nodes || [];
484
+
485
+ for (const d of list) {
486
+ if (d.createdAt >= startUtc && d.createdAt <= endUtc) nodes.push(d);
487
+ }
488
+
489
+ const oldest = list.length ? list[list.length - 1].createdAt : null;
490
+ const hasNext = page?.pageInfo?.hasNextPage;
491
+
492
+ if (!hasNext) break;
493
+ if (!oldest) break;
494
+ if (oldest < startUtc) break;
495
+
496
+ after = page.pageInfo.endCursor;
497
+ }
498
+
499
+ let finalText;
500
+
501
+ try {
502
+ if (nodes.length === 0) {
503
+ finalText = `昨日は投稿がなかったみたいだね!えらい、ちゃんと休息も取れてる!
504
+ 昨日の松戸は${dominantWeather.icon}${dominantWeather.label}だったよ。お外でもストレッチできたかな?
505
+ 今日の一言:背すじスッと、ニコッといこー!`;
506
+ } else {
507
+ const { source, usedChars } = buildPromptSource(nodes, {
508
+ maxPerBodyChars: 600,
509
+ maxTotalChars: 2500,
510
+ });
511
+
512
+ const system = `あなたは「体操のお兄さん」風の文章を書くプロです。
513
+ トーンは優しめで、軽いコメディ(ツッコミ)を入れてください。
514
+ 誹謗中傷や攻撃的表現は避けてください。`;
515
+
516
+ const user = `
517
+ 昨日(JST: ${dayJst})の松戸市の日中の天気は ${dominantWeather.label} でした。
518
+ 縄跳びは、${yesterdayjumpcount}でした。
519
+
520
+ 以下は昨日(JST: ${dayJst})に投稿されたGitHub Discussionsです。本文は抜粋で、長文は省略されています。
521
+ この内容を踏まえて、次を日本語で生成してください。
522
+
523
+ 要件:
524
+ - 300文字前後(±80文字くらいはOK)
525
+ - 適切な位置で改行(重要)
526
+ - 「昨日のまとめ」に対する優しいお話
527
+ - 天気にも一言触れてOK(無理に入れなくてもOK)
528
+ - 「今日の一言」(最後に「今日の一言:...」の形式で1文)
529
+ - 固有名詞やURLは無理に入れなくてOK(入れるなら1つまで)
530
+ - 出力は文章だけ(箇条書きや見出しは不要)
531
+ - コメディアンでユーモラスに
532
+ - あなたは体操のお兄さんです!元気もりもり!
533
+ Discussions(抜粋、合計 ${usedChars} 文字):
534
+ ${source}
535
+ `.trim();
536
+
537
+ const text = await cfAiChat({
538
+ accountId: CF_ACCOUNT_ID,
539
+ apiToken: CF_API_TOKEN,
540
+ model: CF_MODEL,
541
+ messages: [
542
+ { role: "system", content: system },
543
+ { role: "user", content: user },
544
+ ],
545
+ temperature:1.5,
546
+ });
547
+
548
+ finalText = text;
549
+ }
550
+ } catch (e) {
551
+ console.warn("Generation failed. Falling back to rest message.");
552
+ console.warn(String(e?.message || e));
553
+
554
+ finalText = `ごめんね、今日はお兄さんの声がちょっと裏返っちゃった!機械も準備体操が必要なんだ〜。
555
+ 昨日の分はアーカイブにちゃんと残してあるから安心してね。
556
+ 今日の一言:うまくいかない日こそ、ストレッチして笑って切り替えよう!`;
557
+ }
558
+
559
+ const newBlock = buildReadmeBlock({ dayJst, text: finalText, dominantWeather });
560
+
561
+ const readme = fs.readFileSync("README.md", "utf8");
562
+ const updated = replaceBlock(readme, newBlock);
563
+ fs.writeFileSync("README.md", updated, "utf8");
564
+
565
+ console.log(`README updated for JST ${dayJst}. discussions=${nodes.length}`);
566
+ }
567
+
568
+ main().catch((e) => {
569
+ console.error(e);
570
+ process.exit(1);
571
+ });
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "@oniisan/htvoffcial",
3
+ "version": "0.1.1",
4
+ "description": "GitHub automation scripts for the Taiso no Oniisan project",
5
+ "type": "module",
6
+ "private": false,
7
+ "bin": {
8
+ "discuss-readme-coach": "./discuss_readme_coach.mjs",
9
+ "post-jpt-zatsudan": "./post_jpt_zatsudan.mjs",
10
+ "translate-discuss-coach-deepl": "./translate-discuss-coach-deepl.mjs",
11
+ "translates1": "./translates1.js"
12
+ },
13
+ "files": [
14
+ "*.mjs",
15
+ "*.js",
16
+ "!node_modules"
17
+ ],
18
+ "scripts": {
19
+ "test": "node --test || true"
20
+ },
21
+ "engines": {
22
+ "node": ">=20"
23
+ },
24
+ "keywords": [
25
+ "github-actions",
26
+ "automation",
27
+ "discussions"
28
+ ],
29
+ "license": "SSPL-1.0"
30
+ }
@@ -0,0 +1,354 @@
1
+ /*
2
+ Copyright (C) 2026 htvoffcial
3
+ SPDX-License-Identifier: SSPL-1.0
4
+ */
5
+
6
+ import fs from "node:fs";
7
+ import path from "node:path";
8
+
9
+ const GH_TOKEN = process.env.GH_TOKEN;
10
+ const REPOSITORY = process.env.GITHUB_REPOSITORY;
11
+
12
+ if (!GH_TOKEN) throw new Error("GH_TOKEN is missing");
13
+ if (!REPOSITORY) throw new Error("GITHUB_REPOSITORY is missing");
14
+
15
+ const [owner, repo] = REPOSITORY.split("/");
16
+ if (!owner || !repo) throw new Error(`Invalid GITHUB_REPOSITORY: ${REPOSITORY}`);
17
+
18
+ const POS_TAGS = [
19
+ "形容動詞",
20
+ "接続詞",
21
+ "形容詞",
22
+ "代名詞",
23
+ "連体詞",
24
+ "感動詞",
25
+ "副詞",
26
+ "動詞",
27
+ "名詞",
28
+ ];
29
+
30
+ // 一段動詞に見えるが五段動詞である代表的な例外
31
+ const GODAN_EXCEPTIONS = new Set([
32
+ "走る", "帰る", "切る", "知る", "入る", "要る", "減る", "喋る", "限る", "握る"
33
+ ]);
34
+
35
+ /**
36
+ * 動詞の簡易活用関数(自然な接続にするための変換)
37
+ */
38
+ function conjugateVerb(verb, form) {
39
+ if (!verb || verb.length < 1) return verb;
40
+
41
+ // 不規則動詞の処理
42
+ if (verb === "する" || verb.endsWith("する")) {
43
+ const base = verb.slice(0, -2);
44
+ if (form === "連用" || form === "未然") return base + "し";
45
+ if (form === "て" || form === "た") return base + "した";
46
+ return verb;
47
+ }
48
+ if (verb === "来る" || verb === "くる") {
49
+ const isKanji = verb === "来る";
50
+ if (form === "連用") return isKanji ? "来" : "き";
51
+ if (form === "未然") return isKanji ? "来" : "こ";
52
+ if (form === "て" || form === "た") return isKanji ? "来た" : "きた";
53
+ return verb;
54
+ }
55
+ if (verb === "行く" && (form === "て" || form === "た")) {
56
+ return "行った";
57
+ }
58
+
59
+ const lastChar = verb.slice(-1);
60
+ const stem = verb.slice(0, -1);
61
+ const prevChar = verb.length >= 2 ? verb.slice(-2, -1) : "";
62
+
63
+ // 一段動詞か五段動詞かの判定
64
+ const isIchidan =
65
+ lastChar === "る" &&
66
+ /[いきしちにひみりえけせてねへめれ]/.test(prevChar) &&
67
+ !GODAN_EXCEPTIONS.has(verb);
68
+
69
+ if (isIchidan) {
70
+ if (form === "連用" || form === "未然") return stem;
71
+ if (form === "て" || form === "た") return stem + "た";
72
+ return verb;
73
+ }
74
+
75
+ // 五段動詞の活用
76
+ if (form === "連用") {
77
+ const map = { う: "い", く: "き", ぐ: "ぎ", す: "し", つ: "ち", ぬ: "に", ぶ: "び", む: "み", る: "り" };
78
+ return stem + (map[lastChar] || lastChar);
79
+ }
80
+ if (form === "未然") {
81
+ const map = { う: "わ", く: "か", ぐ: "が", す: "さ", つ: "た", ぬ: "な", ぶ: "ば", む: "ま", る: "ら" };
82
+ return stem + (map[lastChar] || lastChar);
83
+ }
84
+ if (form === "て" || form === "た") {
85
+ if (["う", "つ", "る"].includes(lastChar)) return stem + "った";
86
+ if (["む", "ぶ", "ぬ"].includes(lastChar)) return stem + "んだ";
87
+ if (lastChar === "く") return stem + "いた";
88
+ if (lastChar === "ぐ") return stem + "いだ";
89
+ if (lastChar === "す") return stem + "した";
90
+ }
91
+
92
+ return verb;
93
+ }
94
+
95
+ /**
96
+ * 形容動詞の語幹整形と活用
97
+ */
98
+ function conjugateNaAdjective(word, nextChars) {
99
+ let stem = word.endsWith("だ") ? word.slice(0, -1) : word;
100
+
101
+ // 後ろが「名詞」や一般的な名詞フレーズなら「〜な」に接続
102
+ if (/^(\{?名詞\}?|[^\s、。!?だで])/.test(nextChars)) {
103
+ return `${stem}な`;
104
+ }
105
+ // 後ろが「です」「でした」なら語幹のまま
106
+ if (/^(です|でした)/.test(nextChars)) {
107
+ return stem;
108
+ }
109
+ // 文末などで直接終わる場合
110
+ if (/^[。!?\s]*$/.test(nextChars)) {
111
+ return `${stem}だ`;
112
+ }
113
+ return stem;
114
+ }
115
+
116
+ function pickRandomUnique(list, usedSet) {
117
+ if (!Array.isArray(list) || list.length === 0) return "";
118
+ const available = list.filter((w) => !usedSet.has(w));
119
+ const pool = available.length > 0 ? available : list;
120
+ const index = Math.floor(Math.random() * pool.length);
121
+ const picked = pool[index] || "";
122
+ usedSet.add(picked);
123
+ return picked;
124
+ }
125
+
126
+ function loadTemplates(baseDir) {
127
+ const filePath = path.join(baseDir, ".github", "assets", "jpt_goj.csv");
128
+ const raw = fs.readFileSync(filePath, "utf8");
129
+ return raw
130
+ .split(/\r?\n/)
131
+ .map((line) => line.trim())
132
+ .filter((line) => line && !line.startsWith("#"));
133
+ }
134
+
135
+ function loadWordsByPos(baseDir) {
136
+ const filePath = path.join(baseDir, ".github", "assets", "jpt_cps.csv");
137
+ const raw = fs.readFileSync(filePath, "utf8");
138
+ const lines = raw
139
+ .split(/\r?\n/)
140
+ .map((line) => line.trim())
141
+ .filter(Boolean);
142
+
143
+ const map = new Map();
144
+
145
+ for (const line of lines.slice(1)) {
146
+ const commaIndex = line.lastIndexOf(",");
147
+ if (commaIndex <= 0) continue;
148
+
149
+ const word = line.slice(0, commaIndex).trim();
150
+ const pos = line.slice(commaIndex + 1).trim();
151
+
152
+ if (!word || !pos) continue;
153
+
154
+ if (!map.has(pos)) map.set(pos, []);
155
+ map.get(pos).push(word);
156
+ }
157
+
158
+ return map;
159
+ }
160
+
161
+ /**
162
+ * テンプレート置換(シングルパス正規表現&コンテキストに応じた自動活用)
163
+ */
164
+ function fillTemplate(template, wordsByPos) {
165
+ const usedWords = new Set();
166
+
167
+ // {名詞} または 名詞 のどちらの表記にも対応
168
+ const pattern = new RegExp(
169
+ `\\{?(${POS_TAGS.join("|")})(?::([a-zA-Z0-9_]+))?\\}?`,
170
+ "g"
171
+ );
172
+
173
+ // シングルパスで置換し、多重置換バグを防止
174
+ let result = template.replace(pattern, (match, pos, explicitForm, offset, fullStr) => {
175
+ const words = wordsByPos.get(pos) || [];
176
+ let word = pickRandomUnique(words, usedWords);
177
+ if (!word) return match;
178
+
179
+ const remaining = fullStr.slice(offset + match.length);
180
+
181
+ // 1. 動詞の自動活用
182
+ if (pos === "動詞") {
183
+ if (explicitForm) {
184
+ word = conjugateVerb(word, explicitForm);
185
+ } else if (/^(ます|ました|ません|たい|つつ|ながら|そう)/.test(remaining)) {
186
+ word = conjugateVerb(word, "連用");
187
+ } else if (/^(て|た|だ)/.test(remaining)) {
188
+ word = conjugateVerb(word, "て");
189
+ } else if (/^(ない|なかった|れる|られる|せる|させる)/.test(remaining)) {
190
+ word = conjugateVerb(word, "未然");
191
+ }
192
+ }
193
+
194
+ // 2. 形容動詞の自動調整
195
+ if (pos === "形容動詞") {
196
+ word = conjugateNaAdjective(word, remaining);
197
+ }
198
+
199
+ return word;
200
+ });
201
+
202
+ // 不自然な助詞の重なりや空白のクリーンアップ
203
+ result = result
204
+ .replace(/[ \t]+/g, " ")
205
+ .replace(/をを/g, "を")
206
+ .replace(/がが/g, "が")
207
+ .replace(/はは/g, "は")
208
+ .replace(/、、+/g, "、")
209
+ .trim();
210
+
211
+ return result;
212
+ }
213
+
214
+ /**
215
+ * より人間らしく自然なディスカッションタイトルの生成
216
+ */
217
+ function buildTitle(sentence) {
218
+ const clean = sentence.trim().replace(/\s+/g, " ");
219
+
220
+ // 35文字以下なら、そのまま自然なタイトルとして採用
221
+ if (clean.length <= 35) {
222
+ return clean;
223
+ }
224
+
225
+ // 最初の1文(「。」や「!?\n」まで)を取得
226
+ const firstSentence = clean.split(/[。!?!?\n]/)[0]?.trim();
227
+ if (firstSentence && firstSentence.length >= 5 && firstSentence.length <= 40) {
228
+ // 疑問文で終わっている場合はそのまま活用
229
+ if (clean.includes("?") || clean.includes("?")) {
230
+ return `${firstSentence}?`;
231
+ }
232
+ return `${firstSentence}`;
233
+ }
234
+
235
+ // 長い場合のフォールバック(不自然に文字を切らず、語尾に…を付与)
236
+ return `${clean.slice(0, 32)}…`;
237
+ }
238
+
239
+ async function ghGraphql(query, variables) {
240
+ const res = await fetch("https://api.github.com/graphql", {
241
+ method: "POST",
242
+ headers: {
243
+ Authorization: `bearer ${GH_TOKEN}`,
244
+ "Content-Type": "application/json",
245
+ },
246
+ body: JSON.stringify({ query, variables }),
247
+ });
248
+
249
+ const json = await res.json().catch(() => null);
250
+
251
+ if (!res.ok || json?.errors?.length) {
252
+ throw new Error(`GraphQL failed: ${res.status} ${JSON.stringify(json)}`);
253
+ }
254
+
255
+ return json.data;
256
+ }
257
+
258
+ async function resolveCategoryAndRepoId() {
259
+ const query = `
260
+ query($owner: String!, $repo: String!) {
261
+ repository(owner: $owner, name: $repo) {
262
+ id
263
+ discussionCategories(first: 50) {
264
+ nodes {
265
+ id
266
+ name
267
+ emoji
268
+ }
269
+ }
270
+ }
271
+ }
272
+ `;
273
+
274
+ const data = await ghGraphql(query, { owner, repo });
275
+ const repository = data?.repository;
276
+
277
+ if (!repository?.id) {
278
+ throw new Error("repository.id was not found");
279
+ }
280
+
281
+ const categories = repository.discussionCategories?.nodes || [];
282
+ const zatsudan = categories.find((c) => String(c?.name || "").includes("雑談"));
283
+
284
+ if (!zatsudan?.id) {
285
+ throw new Error("Discussion category '雑談' was not found");
286
+ }
287
+
288
+ return {
289
+ repositoryId: repository.id,
290
+ categoryId: zatsudan.id,
291
+ };
292
+ }
293
+
294
+ async function createDiscussion({ repositoryId, categoryId, title, body }) {
295
+ const mutation = `
296
+ mutation($repositoryId: ID!, $categoryId: ID!, $title: String!, $body: String!) {
297
+ createDiscussion(input: {
298
+ repositoryId: $repositoryId,
299
+ categoryId: $categoryId,
300
+ title: $title,
301
+ body: $body
302
+ }) {
303
+ discussion {
304
+ id
305
+ url
306
+ title
307
+ }
308
+ }
309
+ }
310
+ `;
311
+
312
+ const data = await ghGraphql(mutation, {
313
+ repositoryId,
314
+ categoryId,
315
+ title,
316
+ body,
317
+ });
318
+
319
+ return data?.createDiscussion?.discussion;
320
+ }
321
+
322
+ async function main() {
323
+ const baseDir = process.cwd();
324
+
325
+ const templates = loadTemplates(baseDir);
326
+ if (templates.length === 0) throw new Error("No templates found in jpt_goj.csv");
327
+
328
+ const wordsByPos = loadWordsByPos(baseDir);
329
+ const template = pickRandomUnique(templates, new Set());
330
+ const sentence = fillTemplate(template, wordsByPos);
331
+ const title = buildTitle(sentence);
332
+
333
+ const { repositoryId, categoryId } = await resolveCategoryAndRepoId();
334
+
335
+ const discussion = await createDiscussion({
336
+ repositoryId,
337
+ categoryId,
338
+ title,
339
+ body: sentence,
340
+ });
341
+
342
+ if (!discussion?.url) {
343
+ throw new Error("Failed to create discussion");
344
+ }
345
+
346
+ console.log(`Created discussion: ${discussion.url}`);
347
+ console.log(`Title: ${discussion.title}`);
348
+ console.log(`Body: ${sentence}`);
349
+ }
350
+
351
+ main().catch((error) => {
352
+ console.error(error);
353
+ process.exit(1);
354
+ });
@@ -0,0 +1,86 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ const START = "<!-- DISCUSS_COACH_START -->";
5
+ const END = "<!-- DISCUSS_COACH_END -->";
6
+
7
+ function extractBetween(text) {
8
+ const startIdx = text.indexOf(START);
9
+ const endIdx = text.indexOf(END);
10
+ if (startIdx === -1 || endIdx === -1 || endIdx <= startIdx) return null;
11
+ return text.slice(startIdx + START.length, endIdx).trim();
12
+ }
13
+
14
+ function extractSourceDateJST(text) {
15
+ // README中にある: **対象日(JST):** 2026-04-17
16
+ const m = text.match(/\*\*対象日(JST):\*\*\s*([0-9]{4}-[0-9]{2}-[0-9]{2})/);
17
+ return m?.[1] ?? "";
18
+ }
19
+
20
+ async function deeplTranslate(text, { apiKey, baseUrl }) {
21
+ if (!apiKey) throw new Error("Missing DEEPL_API_KEY secret");
22
+
23
+ // baseUrlは secrets に入れるのが安全(Free/Pro切替)
24
+ const endpoint = (baseUrl || "https://api-free.deepl.com").replace(/\/$/, "") + "/v2/translate";
25
+
26
+ const params = new URLSearchParams();
27
+ params.append("text", text);
28
+ params.append("target_lang", "EN"); // 例: "EN" or "EN-US" or "EN-GB"
29
+ // params.append("source_lang", "JA"); // 必要なら固定
30
+ params.append("preserve_formatting", "1");
31
+
32
+ // DeepL API: Authorization: DeepL-Auth-Key xxx
33
+ const res = await fetch(endpoint, {
34
+ method: "POST",
35
+ headers: {
36
+ "Content-Type": "application/x-www-form-urlencoded",
37
+ Authorization: `DeepL-Auth-Key ${apiKey}`,
38
+ },
39
+ body: params.toString(),
40
+ });
41
+
42
+ if (!res.ok) {
43
+ const body = await res.text();
44
+ throw new Error(`DeepL API error: ${res.status} ${body}`);
45
+ }
46
+
47
+ const data = await res.json();
48
+ const translated = data?.translations?.[0]?.text;
49
+ if (!translated) throw new Error("DeepL returned no translation text");
50
+ return String(translated).trim();
51
+ }
52
+
53
+ async function main() {
54
+ const readmePath = path.resolve("README.md");
55
+ const src = fs.readFileSync(readmePath, "utf8");
56
+
57
+ const inner = extractBetween(src);
58
+ if (!inner) throw new Error("Could not find DISCUSS_COACH markers in README.md");
59
+
60
+ const sourceDate = extractSourceDateJST(inner);
61
+
62
+ const english = await deeplTranslate(inner, {
63
+ apiKey: process.env.DEEPL_API_KEY,
64
+ baseUrl: process.env.DEEPL_API_BASE_URL,
65
+ });
66
+
67
+ const outDir = path.resolve(".github/English");
68
+ fs.mkdirSync(outDir, { recursive: true });
69
+
70
+ const outPath = path.join(outDir, "README.md");
71
+
72
+
73
+
74
+ const out = `
75
+ ${english}
76
+ ${END}
77
+ `;
78
+
79
+ fs.writeFileSync(outPath, out, "utf8");
80
+ console.log(`Wrote ${outPath}`);
81
+ }
82
+
83
+ main().catch((err) => {
84
+ console.error(err);
85
+ process.exit(1);
86
+ });
package/translates1.js ADDED
@@ -0,0 +1,336 @@
1
+ (() => {
2
+ const STORAGE_KEY = "certinaly_lang_barrier_v2";
3
+
4
+ // === 170+ Countries (ISO 3166-1 alpha-2) ===
5
+ const ALL_COUNTRIES = [
6
+ "AF","AL","DZ","AD","AO","AR","AM","AU","AT","AZ","BS","BH","BD","BB","BY","BE","BZ","BJ","BT","BO","BA","BW","BR","BN","BG","BF","BI","KH","CM","CA",
7
+ "CV","CF","TD","CL","CN","CO","KM","CG","CD","CR","CI","HR","CU","CY","CZ","DK","DJ","DM","DO","EC","EG","SV","GQ","ER","EE","ET","FJ","FI","FR",
8
+ "GA","GM","GE","DE","GH","GR","GD","GT","GN","GW","GY","HT","HN","HU","IS","IN","ID","IR","IQ","IE","IL","IT","JM","JP","JO","KZ","KE","KI","KP","KR",
9
+ "KW","KG","LA","LV","LB","LS","LR","LY","LI","LT","LU","MG","MW","MY","MV","ML","MT","MR","MU","MX","MD","MC","MN","ME","MA","MZ","MM","NA","NR","NP",
10
+ "NL","NZ","NI","NE","NG","NO","OM","PK","PA","PG","PY","PE","PH","PL","PT","QA","RO","RU","RW","SA","SN","RS","SC","SL","SG","SK","SI","SB","SO",
11
+ "ZA","ES","LK","SD","SR","SZ","SE","CH","SY","TW","TJ","TZ","TH","TL","TG","TO","TT","TN","TR","TM","UG","UA","AE","GB","US","UY","UZ","VE","VN",
12
+ "YE","ZM","ZW"
13
+ ];
14
+
15
+ // === Country → Language overrides ===
16
+ const COUNTRY_GROUPS = {
17
+ ja: ["JP"],
18
+ zh: ["CN","TW","HK","MO"],
19
+ ko: ["KR","KP"],
20
+ ru: ["RU","BY","KZ","KG","TJ","UZ"],
21
+ uk: ["UA"],
22
+ ar: ["SA","AE","QA","BH","KW","OM","YE","EG","SD","LY","TN","DZ","MA","JO","LB","SY","IQ","PS","MR","SO"],
23
+ fa: ["IR","AF"],
24
+ he: ["IL"],
25
+ el: ["GR","CY"],
26
+ tr: ["TR","AZ","TM"],
27
+ hi: ["IN","NP"],
28
+ bn: ["BD"],
29
+ ur: ["PK"],
30
+ th: ["TH"],
31
+ vi: ["VN"],
32
+ id: ["ID"],
33
+ ms: ["MY","BN","SG"],
34
+ de: ["DE","AT","CH","LI","LU"],
35
+ fr: ["FR","BE","CI","SN","ML","NE","BF","BJ","TG","CM","GA","GN","CG","CD","MG","TD","DJ","KM","SC"],
36
+ es: ["ES","MX","AR","CO","CL","PE","VE","EC","BO","UY","PY","CR","GT","SV","HN","NI","PA","DO","CU","GQ"],
37
+ pt: ["PT","BR","AO","MZ","CV","GW","ST","TL"],
38
+ it: ["IT","SM","VA"],
39
+ nl: ["NL","SR"],
40
+ sv: ["SE"],
41
+ no: ["NO"],
42
+ da: ["DK"],
43
+ fi: ["FI"],
44
+ pl: ["PL"],
45
+ cs: ["CZ"],
46
+ sk: ["SK"],
47
+ hu: ["HU"],
48
+ ro: ["RO","MD"],
49
+ bg: ["BG"]
50
+ };
51
+
52
+ // === Build Country→Lang map (default to latin) ===
53
+ const COUNTRY_LANG = Object.fromEntries(ALL_COUNTRIES.map(c => [c, "latin"]));
54
+ Object.entries(COUNTRY_GROUPS).forEach(([lang, codes]) => {
55
+ codes.forEach(code => COUNTRY_LANG[code] = lang);
56
+ });
57
+
58
+ // === Character sets ===
59
+ const latinChars =
60
+ "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
61
+ const cyrillicChars =
62
+ "АБВГДЕЁЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯ" +
63
+ "абвгдеёжзийклмнопрстуфхцчшщъыьэюя";
64
+ const arabicChars = Array.from({ length: 256 }, (_, i) => String.fromCharCode(0x0600 + i)).join("");
65
+ const hanChars = Array.from({ length: 1500 }, (_, i) => String.fromCharCode(0x4E00 + i)).join("");
66
+ const devanagariChars = Array.from({ length: 128 }, (_, i) => String.fromCharCode(0x0900 + i)).join("");
67
+ const thaiChars = Array.from({ length: 128 }, (_, i) => String.fromCharCode(0x0E00 + i)).join("");
68
+ const hebrewChars = Array.from({ length: 96 }, (_, i) => String.fromCharCode(0x0590 + i)).join("");
69
+ const greekChars = Array.from({ length: 96 }, (_, i) => String.fromCharCode(0x0370 + i)).join("");
70
+ const hangulChars = Array.from({ length: 512 }, (_, i) => String.fromCharCode(0xAC00 + i)).join("");
71
+ const englishVowelPool = "eeeeaaaaiiiooouuuy";
72
+ const englishConsonantPool = "ttttrrrrssssnnnlllccchhddmmffggppbbvvkkjxxqqzz";
73
+ const englishVowels = "aeiouy";
74
+
75
+ // === Language configs ===
76
+ const LANG_META = {
77
+ ja: { label: "日本語", chars: null, font: "sans-serif", dir: "ltr" },
78
+ en: { label: "English", chars: latinChars, font: "sans-serif", dir: "ltr" },
79
+ latin: { label: "Latin", chars: latinChars, font: "sans-serif", dir: "ltr" },
80
+ es: { label: "Español", chars: latinChars, font: "sans-serif", dir: "ltr" },
81
+ fr: { label: "Français", chars: latinChars, font: "sans-serif", dir: "ltr" },
82
+ pt: { label: "Português", chars: latinChars, font: "sans-serif", dir: "ltr" },
83
+ de: { label: "Deutsch", chars: latinChars, font: "sans-serif", dir: "ltr" },
84
+ it: { label: "Italiano", chars: latinChars, font: "sans-serif", dir: "ltr" },
85
+ nl: { label: "Nederlands", chars: latinChars, font: "sans-serif", dir: "ltr" },
86
+ sv: { label: "Svenska", chars: latinChars, font: "sans-serif", dir: "ltr" },
87
+ no: { label: "Norsk", chars: latinChars, font: "sans-serif", dir: "ltr" },
88
+ da: { label: "Dansk", chars: latinChars, font: "sans-serif", dir: "ltr" },
89
+ fi: { label: "Suomi", chars: latinChars, font: "sans-serif", dir: "ltr" },
90
+ pl: { label: "Polski", chars: latinChars, font: "sans-serif", dir: "ltr" },
91
+ cs: { label: "Čeština", chars: latinChars, font: "sans-serif", dir: "ltr" },
92
+ sk: { label: "Slovenčina", chars: latinChars, font: "sans-serif", dir: "ltr" },
93
+ hu: { label: "Magyar", chars: latinChars, font: "sans-serif", dir: "ltr" },
94
+ ro: { label: "Română", chars: latinChars, font: "sans-serif", dir: "ltr" },
95
+ bg: { label: "Български", chars: cyrillicChars, font: "'Ruslan Display', cursive", dir: "ltr" },
96
+ ru: { label: "Русский", chars: cyrillicChars, font: "'Ruslan Display', cursive", dir: "ltr" },
97
+ uk: { label: "Українська", chars: cyrillicChars, font: "'Ruslan Display', cursive", dir: "ltr" },
98
+ ar: { label: "العربية", chars: arabicChars, font: "'Amiri', serif", dir: "rtl" },
99
+ fa: { label: "فارسی", chars: arabicChars, font: "'Amiri', serif", dir: "rtl" },
100
+ he: { label: "עברית", chars: hebrewChars, font: "'Noto Serif Hebrew', serif", dir: "rtl" },
101
+ el: { label: "Ελληνικά", chars: greekChars, font: "'Noto Serif Greek', serif", dir: "ltr" },
102
+ zh: { label: "中文", chars: hanChars, font: "'Zhi Mang Xing', cursive", dir: "ltr" },
103
+ ko: { label: "한국어", chars: hangulChars, font: "'Noto Sans KR', sans-serif", dir: "ltr" },
104
+ hi: { label: "हिन्दी", chars: devanagariChars, font: "'Noto Sans Devanagari', sans-serif", dir: "ltr" },
105
+ bn: { label: "বাংলা", chars: devanagariChars, font: "'Noto Sans Devanagari', sans-serif", dir: "ltr" },
106
+ ur: { label: "اردو", chars: arabicChars, font: "'Amiri', serif", dir: "rtl" },
107
+ th: { label: "ไทย", chars: thaiChars, font: "'Noto Sans Thai', sans-serif", dir: "ltr" },
108
+ vi: { label: "Tiếng Việt", chars: latinChars, font: "sans-serif", dir: "ltr" },
109
+ id: { label: "Bahasa Indonesia", chars: latinChars, font: "sans-serif", dir: "ltr" },
110
+ ms: { label: "Bahasa Melayu", chars: latinChars, font: "sans-serif", dir: "ltr" }
111
+ };
112
+
113
+ const LANG_ORDER = [
114
+ "ja","en","latin","es","fr","pt","de","it","nl","sv","no","da","fi","pl","cs","sk","hu","ro","bg",
115
+ "ru","uk","ar","fa","he","el","zh","ko","hi","bn","ur","th","vi","id","ms"
116
+ ];
117
+
118
+ // === Load fonts (once) ===
119
+ const fontsHref =
120
+ "https://fonts.googleapis.com/css2" +
121
+ "?family=Amiri" +
122
+ "&family=Ruslan+Display" +
123
+ "&family=Zhi+Mang+Xing" +
124
+ "&family=Noto+Sans+KR" +
125
+ "&family=Noto+Sans+Thai" +
126
+ "&family=Noto+Sans+Devanagari" +
127
+ "&family=Noto+Serif+Hebrew" +
128
+ "&family=Noto+Serif+Greek" +
129
+ "&display=swap";
130
+ if (!document.querySelector(`link[href="${fontsHref}"]`)) {
131
+ const link = document.createElement("link");
132
+ link.rel = "stylesheet";
133
+ link.href = fontsHref;
134
+ document.head.appendChild(link);
135
+ }
136
+
137
+ // === Text node collection ===
138
+ const EXCLUDE_TAGS = new Set(["SCRIPT","STYLE","TEXTAREA","INPUT","OPTION","SELECT","NOSCRIPT"]);
139
+ const originalMap = new WeakMap();
140
+ const textNodes = new Set();
141
+ let currentLang = "ja";
142
+
143
+ function isSkippable(node) {
144
+ if (!node || !node.parentElement) return true;
145
+ const tag = node.parentElement.tagName;
146
+ if (EXCLUDE_TAGS.has(tag)) return true;
147
+ if (node.parentElement.closest("[contenteditable='true']")) return true;
148
+ return false;
149
+ }
150
+
151
+ function collectTextNodes(root) {
152
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
153
+ acceptNode(node) {
154
+ if (isSkippable(node)) return NodeFilter.FILTER_REJECT;
155
+ if (!node.nodeValue || !node.nodeValue.trim()) return NodeFilter.FILTER_REJECT;
156
+ return NodeFilter.FILTER_ACCEPT;
157
+ }
158
+ });
159
+ while (walker.nextNode()) {
160
+ const node = walker.currentNode;
161
+ if (!originalMap.has(node)) originalMap.set(node, node.nodeValue);
162
+ textNodes.add(node);
163
+ }
164
+ }
165
+
166
+ const baseIndexCache = new Map();
167
+ function hashString(value) {
168
+ let hash = 0;
169
+ for (let i = 0; i < value.length; i += 1) {
170
+ hash = ((hash << 5) - hash) + value.charCodeAt(i);
171
+ hash |= 0;
172
+ }
173
+ return hash;
174
+ }
175
+
176
+ function mapWithJitter(charSet, baseKey, jitterSeed, jitterRange, lastMapped) {
177
+ let baseIndex = baseIndexCache.get(baseKey);
178
+ if (baseIndex === undefined) {
179
+ baseIndex = Math.abs(hashString(baseKey)) % charSet.length;
180
+ baseIndexCache.set(baseKey, baseIndex);
181
+ }
182
+
183
+ const jitter = Math.abs(hashString(jitterSeed)) % jitterRange;
184
+ let mappedIndex = (baseIndex + jitter) % charSet.length;
185
+ let mapped = charSet[mappedIndex];
186
+
187
+ if (mapped === lastMapped && charSet.length > 1) {
188
+ mappedIndex = (mappedIndex + 1) % charSet.length;
189
+ mapped = charSet[mappedIndex];
190
+ }
191
+
192
+ return mapped;
193
+ }
194
+
195
+ function scramble(text, charSet, langKey) {
196
+ const jitterRange = Math.min(128, Math.max(8, Math.floor(charSet.length / 4)));
197
+ let lastMapped = "";
198
+ let wordIndex = 0;
199
+ let output = "";
200
+
201
+ for (let index = 0; index < text.length; index += 1) {
202
+ const char = text[index];
203
+ if (/\s/.test(char)) {
204
+ output += char;
205
+ wordIndex += 1;
206
+ lastMapped = "";
207
+ continue;
208
+ }
209
+ if (/([.,!?;:!?。、「」()0-9])/.test(char)) {
210
+ output += char;
211
+ continue;
212
+ }
213
+
214
+ const baseKey = `${langKey}|${char}|${wordIndex}`;
215
+ const jitterSeed = `${baseKey}|${index}`;
216
+ let activeCharSet = charSet;
217
+ let mapped;
218
+
219
+ if (langKey === "en") {
220
+ const lower = char.toLowerCase();
221
+ const isVowel = englishVowels.includes(lower);
222
+ activeCharSet = isVowel ? englishVowelPool : englishConsonantPool;
223
+ mapped = mapWithJitter(activeCharSet, baseKey, jitterSeed, jitterRange, lastMapped);
224
+ if (char !== lower) mapped = mapped.toUpperCase();
225
+ } else {
226
+ mapped = mapWithJitter(activeCharSet, baseKey, jitterSeed, jitterRange, lastMapped);
227
+ }
228
+
229
+ lastMapped = mapped;
230
+ output += mapped;
231
+ }
232
+
233
+ return output;
234
+ }
235
+
236
+ // === Apply language ===
237
+ function applyLanguage(lang) {
238
+ const config = LANG_META[lang] || LANG_META.ja;
239
+ currentLang = lang;
240
+
241
+ document.body.style.fontFamily = config.font;
242
+ document.body.style.direction = config.dir;
243
+
244
+ textNodes.forEach(node => {
245
+ const original = originalMap.get(node) || node.nodeValue;
246
+ if (lang === "ja" || !config.chars) {
247
+ node.nodeValue = original;
248
+ } else {
249
+ node.nodeValue = scramble(original, config.chars, lang);
250
+ }
251
+ });
252
+
253
+ localStorage.setItem(STORAGE_KEY, lang);
254
+ }
255
+
256
+ // === Language detection ===
257
+ function detectLang() {
258
+ const saved = localStorage.getItem(STORAGE_KEY);
259
+ if (saved) return saved;
260
+
261
+ const raw = (navigator.language || navigator.userLanguage || "ja").toLowerCase();
262
+ const parts = raw.split(/[-_]/);
263
+ const lang = parts[0];
264
+ const country = (parts[1] || "").toUpperCase();
265
+
266
+ if (country && COUNTRY_LANG[country]) return COUNTRY_LANG[country];
267
+ if (LANG_META[lang]) return lang;
268
+ return "ja";
269
+ }
270
+
271
+ // === UI ===
272
+ function buildSelector() {
273
+ const container = document.createElement("div");
274
+ container.id = "barrier-container";
275
+ container.style.cssText = `
276
+ display:flex;
277
+ background: gainsboro; padding: 12px;
278
+ border: 2px solid #333;
279
+ width:fit-content;
280
+ justify-content: center;
281
+ align-items: center;
282
+ z-index: 100000; font-family: sans-serif;
283
+ `;
284
+
285
+ const label = document.createElement("label");
286
+ label.htmlFor = "lang-select";
287
+ label.textContent = "Language: ";
288
+ container.appendChild(label);
289
+
290
+ const select = document.createElement("select");
291
+ select.id = "lang-select";
292
+ LANG_ORDER.forEach(key => {
293
+ if (!LANG_META[key]) return;
294
+ const opt = document.createElement("option");
295
+ opt.value = key;
296
+ opt.textContent = LANG_META[key].label;
297
+ select.appendChild(opt);
298
+ });
299
+ select.addEventListener("change", e => applyLanguage(e.target.value));
300
+
301
+ container.appendChild(select);
302
+ document.body.appendChild(container);
303
+ }
304
+
305
+ // === Init ===
306
+ function init() {
307
+ collectTextNodes(document.body);
308
+ buildSelector();
309
+
310
+ const initial = detectLang();
311
+ const select = document.getElementById("lang-select");
312
+ if (select) select.value = initial;
313
+ applyLanguage(initial);
314
+
315
+ // Observe dynamic content
316
+ const observer = new MutationObserver(mutations => {
317
+ mutations.forEach(m => {
318
+ m.addedNodes.forEach(node => {
319
+ if (node.nodeType === 1 || node.nodeType === 11) {
320
+ collectTextNodes(node);
321
+ } else if (node.nodeType === 3) {
322
+ collectTextNodes(node.parentNode || document.body);
323
+ }
324
+ });
325
+ });
326
+ if (currentLang !== "ja") applyLanguage(currentLang);
327
+ });
328
+ observer.observe(document.body, { childList: true, subtree: true });
329
+ }
330
+
331
+ if (document.readyState === "loading") {
332
+ document.addEventListener("DOMContentLoaded", init);
333
+ } else {
334
+ init();
335
+ }
336
+ })();