Compare commits

...

26 Commits

Author SHA1 Message Date
akira
4d6adeafdc ドキュメント作成 2026-04-04 09:57:03 +09:00
akira
8c90fe79bc Alexaの認証を延長するもの 2026-04-04 09:54:20 +09:00
akira
555940d8f4 未コミットを一括コミット 2026-04-04 09:15:09 +09:00
Akira
ef7c9d3c21 メールフィルターアップデート 2026-04-04 09:03:05 +09:00
Akira
8ef4cfd81e mail_filter: map To recipients to xserver mailbox codes 2026-03-05 15:02:54 +09:00
Akira
dccca90835 mail_filter: use per-mailbox xserver account codes 2026-03-05 14:25:08 +09:00
Akira
bb958b3554 docs: アーカイブ後にAlexa中間文書を整理削除 2026-03-04 12:31:42 +09:00
Akira
70f842f00e docs: Alexa TTS文書を30番に一本化しアーカイブ索引を追加 2026-03-04 12:28:02 +09:00
Akira
be5fd5a75b Merge branch 'main' of https://gitea.keinafarm.net/akira/windmill_workflow
# Conflicts:
#	docs/flow-manage/10_マスタードキュメント_Windmillフロー管理_API一本化編.md
2026-03-04 12:05:16 +09:00
Akira
0f6a2caa41 引き継ぎ用ドキュメントを作成しました。
今回の件は最初から再現可能な形で記録済みです。

新規作成: 11_引き継ぎ_alexa_speak_API反映後にUIドロップダウンが変わらない件.md
追記更新: 10_マスタードキュメント_Windmillフロー管理_API一本化編.md
記載内容は以下を含みます。

事象概要(API反映済みなのにUIが入力欄のまま)
当日の時系列
確認済み事実(hash・schemaキー)
想定原因
再現時の標準対応手順(API確認 -> UI再読込 -> Edit -> Deploy)
今後の引き継ぎ時チェックポイント
必要ならこのままコミット用メッセージ案も作ります。
2026-03-04 02:00:13 +09:00
Akira
9dec4b3ace ワークフロー全体の情報取得機能追加 2026-03-03 16:13:46 +09:00
Akira
d129777bf1 | 2026-03-03 | 運用単位を workflow package(flow + schedules)へ変更し、実装計画とRunbookを更新 |
| 2026-03-03 | `delete -> create` と hash 管理の運用ガード(preflight / fail closed / post-verify)およびCRLF対策を追記 |
2026-03-03 15:49:07 +09:00
Akira
5a0a668a8a 取り込み二回目 2026-03-03 15:42:12 +09:00
Akira
9c67910f3d サーバーからフローを取得 2026-03-03 15:25:09 +09:00
Akira
0359072c30 pushテスト 2026-03-03 15:04:40 +09:00
Akira
f771e6bcf7 windmillワークフローの管理手順の変更仕様 2026-03-03 14:39:57 +09:00
Akira
d5bb7f24dd Claude Codeによる更新 2026-03-03 13:25:25 +09:00
Akira
4954cc0741 マスタードキュメント 2026-03-03 13:09:11 +09:00
Akira
1be261c95f ドキュメント作成の準備 2026-03-03 12:56:26 +09:00
Akira
07258bb46d ローカルで日本語を発話するようになった 2026-03-03 12:37:26 +09:00
Akira
fe9ee0147c ターゲットを変更 2026-03-03 12:02:55 +09:00
Akira
ee59724093 server.js を3点変更しました:
locale: 'ja-JP' → locale: ''(ローカルPCで成功していた設定)
Content-Length ヘッダーを除去(test_tts.js では送っていなかった、これが差異の一つ)
デバッグログを追加(Amazonへのリクエスト内容とレスポンスをログ出力)
認証問題について

test_tts.js(ローカルで成功)と server.js(サーバーで失敗)を比較した結果、実は使っているCookieは全く同じもの(.env から読み込んでいる)なので、認証情報自体の差は本来ないはずです。

ただし、気になる点が1つあります:

test_tts.js は locale: 'ja-JP' でローカルから成功しているのに、server.js は locale: 'ja-JP' でサーバーから失敗している

これは実は「どこから接続しているか(IPアドレス) で Amazon 側の挙動が変わっている」可能性を示唆します。ただし、先ほど確認したように keinafarm.net は大阪のIPなので、この説明も矛盾します。

デプロイ手順:

bash
# ローカルから scp でサーバーへ転送
scp alexa-api/server.js keinafarm-claude:/home/claude/alexa-api/server.js
# サーバーへSSHしてビルド&再起動
ssh keinafarm-claude 'cd /home/claude/alexa-api && sudo docker compose build && sudo docker compose up -d && sudo docker restart traefik'
デプロイ後、sudo docker logs alexa_api -f でログを確認して、[DEBUG] 行の内容を教えてください。どんな JSON が Amazon に送られているか、Amazon が何を返しているかが見えてきます。
2026-03-03 11:46:02 +09:00
Akira
b2a4012ab5 英語もダメ 2026-03-03 11:09:42 +09:00
Akira
9cbacec4c0 英語もだめ 2026-03-03 11:03:50 +09:00
Akira
dcbf599015 英語も発話しなくなった 2026-03-03 10:54:38 +09:00
Akira
9eb3c41584 日本語が発話されない件の2 2026-03-03 10:47:59 +09:00
52 changed files with 8840 additions and 3030 deletions

View File

@@ -11,7 +11,10 @@
"Bash(printf:*)", "Bash(printf:*)",
"Bash(~/.git-credentials)", "Bash(~/.git-credentials)",
"Bash(git add:*)", "Bash(git add:*)",
"Bash(git commit:*)" "Bash(git commit:*)",
"Bash(python -m json.tool)",
"Bash(AMAZON_EMAIL=\"akiracraftwork@gmail.com\" AMAZON_PASSWORD=\"Makomanai1225\" node auth4.js)",
"Bash(AMAZON_EMAIL=\"akiracraftwork@gmail.com\" AMAZON_PASSWORD=\"txready2\" node auth4.js)"
] ]
} }
} }

1
.gitattributes vendored Normal file
View File

@@ -0,0 +1 @@
*.sh text eol=lf

2
.serena/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
/cache
/project.local.yml

152
.serena/project.yml Normal file
View File

@@ -0,0 +1,152 @@
# the name by which the project can be referenced within Serena
project_name: "windmill_workflow"
# list of languages for which language servers are started; choose from:
# al bash clojure cpp csharp
# csharp_omnisharp dart elixir elm erlang
# fortran fsharp go groovy haskell
# java julia kotlin lua markdown
# matlab nix pascal perl php
# php_phpactor powershell python python_jedi r
# rego ruby ruby_solargraph rust scala
# swift terraform toml typescript typescript_vts
# vue yaml zig
# (This list may be outdated. For the current list, see values of Language enum here:
# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py
# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
# Note:
# - For C, use cpp
# - For JavaScript, use typescript
# - For Free Pascal/Lazarus, use pascal
# Special requirements:
# Some languages require additional setup/installations.
# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers
# When using multiple languages, the first language server that supports a given file will be used for that file.
# The first language is the default language and the respective language server will be used as a fallback.
# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
languages:
- python
# the encoding used by text files in the project
# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
encoding: "utf-8"
# line ending convention to use when writing source files.
# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default)
# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings.
line_ending:
# The language backend to use for this project.
# If not set, the global setting from serena_config.yml is used.
# Valid values: LSP, JetBrains
# Note: the backend is fixed at startup. If a project with a different backend
# is activated post-init, an error will be returned.
language_backend:
# whether to use project's .gitignore files to ignore files
ignore_all_files_in_gitignore: true
# list of additional paths to ignore in this project.
# Same syntax as gitignore, so you can use * and **.
# Note: global ignored_paths from serena_config.yml are also applied additively.
ignored_paths: []
# whether the project is in read-only mode
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
# Added on 2025-04-18
read_only: false
# list of tool names to exclude.
# This extends the existing exclusions (e.g. from the global configuration)
#
# Below is the complete list of tools for convenience.
# To make sure you have the latest list of tools, and to view their descriptions,
# execute `uv run scripts/print_tool_overview.py`.
#
# * `activate_project`: Activates a project by name.
# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
# * `create_text_file`: Creates/overwrites a file in the project directory.
# * `delete_lines`: Deletes a range of lines within a file.
# * `delete_memory`: Deletes a memory from Serena's project-specific memory store.
# * `execute_shell_command`: Executes a shell command.
# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced.
# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type).
# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type).
# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
# * `initial_instructions`: Gets the initial instructions for the current project.
# Should only be used in settings where the system prompt cannot be set,
# e.g. in clients you have no control over, like Claude Desktop.
# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
# * `insert_at_line`: Inserts content at a given line in a file.
# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
# * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
# * `list_memories`: Lists memories in Serena's project-specific memory store.
# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context).
# * `read_file`: Reads a file within the project directory.
# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store.
# * `remove_project`: Removes a project from the Serena configuration.
# * `replace_lines`: Replaces a range of lines within a file with new content.
# * `replace_symbol_body`: Replaces the full definition of a symbol.
# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen.
# * `search_for_pattern`: Performs a search for a pattern in the project.
# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase.
# * `switch_modes`: Activates modes by providing a list of their names
# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information.
# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task.
# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed.
# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store.
excluded_tools: []
# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default).
# This extends the existing inclusions (e.g. from the global configuration).
included_optional_tools: []
# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools.
# This cannot be combined with non-empty excluded_tools or included_optional_tools.
fixed_tools: []
# list of mode names to that are always to be included in the set of active modes
# The full set of modes to be activated is base_modes + default_modes.
# If the setting is undefined, the base_modes from the global configuration (serena_config.yml) apply.
# Otherwise, this setting overrides the global configuration.
# Set this to [] to disable base modes for this project.
# Set this to a list of mode names to always include the respective modes for this project.
base_modes:
# list of mode names that are to be activated by default.
# The full set of modes to be activated is base_modes + default_modes.
# If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply.
# Otherwise, this overrides the setting from the global configuration (serena_config.yml).
# This setting can, in turn, be overridden by CLI parameters (--mode).
default_modes:
# initial prompt for the project. It will always be given to the LLM upon activating the project
# (contrary to the memories, which are loaded on demand).
initial_prompt: ""
# time budget (seconds) per tool call for the retrieval of additional symbol information
# such as docstrings or parameter information.
# This overrides the corresponding setting in the global configuration; see the documentation there.
# If null or missing, use the setting from the global configuration.
symbol_info_budget:
# list of regex patterns which, when matched, mark a memory entry as readonly.
# Extends the list from the global configuration, merging the two lists.
read_only_memory_patterns: []
# list of regex patterns for memories to completely ignore.
# Matching memories will not appear in list_memories or activate_project output
# and cannot be accessed via read_memory or write_memory.
# To access ignored memory files, use the read_file tool on the raw file path.
# Extends the list from the global configuration, merging the two lists.
# Example: ["_archive/.*", "_episodes/.*"]
ignored_memory_patterns: []
# advanced configuration option allowing to configure language server-specific options.
# Maps the language key to the options.
# Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available.
# No documentation on options means no options are available.
ls_specific_settings: {}

View File

@@ -0,0 +1,35 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ $# -ne 1 ]]; then
echo "usage: $0 /tmp/alexa-api.env" >&2
exit 2
fi
SRC_ENV="$1"
DEST_ENV="/home/claude/alexa-api/.env"
DEST_DIR="/home/claude/alexa-api"
COMPOSE_FILE="/home/claude/alexa-api/docker-compose.yml"
if [[ "$SRC_ENV" != /tmp/* ]]; then
echo "source env must be under /tmp" >&2
exit 2
fi
if [[ ! -f "$SRC_ENV" ]]; then
echo "source env not found: $SRC_ENV" >&2
exit 1
fi
grep -q '^ALEXA_COOKIE=' "$SRC_ENV" || {
echo "ALEXA_COOKIE entry not found in $SRC_ENV" >&2
exit 1
}
install -d -m 755 "$DEST_DIR"
install -m 600 "$SRC_ENV" "$DEST_ENV"
docker compose --env-file "$DEST_ENV" -f "$COMPOSE_FILE" restart
rm -f "$SRC_ENV"
echo "alexa cookie deployed"

View File

@@ -0,0 +1 @@
akira ALL=(root) NOPASSWD: /usr/local/bin/alexa-cookie-deploy.sh /tmp/alexa-api.env

239
alexa-api/auth4-core.js Normal file
View File

@@ -0,0 +1,239 @@
const https = require('https');
const fs = require('fs');
const path = require('path');
const ALEXA_LOGIN_URL =
'https://www.amazon.co.jp/ap/signin?' +
new URLSearchParams({
'openid.assoc_handle': 'amzn_dp_project_dee_jp',
'openid.mode': 'checkid_setup',
'openid.ns': 'http://specs.openid.net/auth/2.0',
'openid.claimed_id': 'http://specs.openid.net/auth/2.0/identifier_select',
'openid.identity': 'http://specs.openid.net/auth/2.0/identifier_select',
'openid.return_to': 'https://alexa.amazon.co.jp/api/apps/v1/token',
'pageId': 'amzn_dp_project_dee_jp',
}).toString();
const USER_AGENT =
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Safari/537.36';
function createLogger(logger) {
if (!logger) return function() {};
if (typeof logger === 'function') return logger;
if (typeof logger.log === 'function') {
return function(message) {
logger.log(message);
};
}
return function() {};
}
function createRequestState() {
const cookieJar = {};
function setCookies(setCookieHeaders) {
if (!setCookieHeaders) return;
const headers = Array.isArray(setCookieHeaders) ? setCookieHeaders : [setCookieHeaders];
for (const header of headers) {
const parts = header.split(';');
const kv = parts[0] || '';
const index = kv.indexOf('=');
if (index <= 0) continue;
const key = kv.slice(0, index).trim();
const value = kv.slice(index + 1).trim();
cookieJar[key] = value;
}
}
function getCookieHeader() {
return Object.entries(cookieJar)
.map(([key, value]) => `${key}=${value}`)
.join('; ');
}
function request(url, options) {
const opts = options || {};
return new Promise((resolve, reject) => {
const parsed = new URL(url);
const reqOpts = {
hostname: parsed.hostname,
path: parsed.pathname + parsed.search,
method: opts.method || 'GET',
headers: {
'User-Agent': USER_AGENT,
'Accept-Language': 'ja-JP,ja;q=0.9',
'Accept': 'text/html,application/xhtml+xml,*/*;q=0.8',
'Cookie': getCookieHeader(),
...(opts.headers || {}),
},
};
const req = https.request(reqOpts, (res) => {
setCookies(res.headers['set-cookie']);
let body = '';
res.on('data', (chunk) => {
body += chunk;
});
res.on('end', () => {
resolve({ status: res.statusCode, headers: res.headers, body });
});
});
req.on('error', reject);
if (opts.body) req.write(opts.body);
req.end();
});
}
return {
cookieJar,
getCookieHeader,
request,
};
}
function extractHiddenFields(html) {
const fields = {};
const re = /<input[^>]+type=["']?hidden["']?[^>]*>/gi;
let match;
while ((match = re.exec(html)) !== null) {
const tag = match[0];
const name = (tag.match(/name=["']([^"']+)["']/) || [])[1];
const value = (tag.match(/value=["']([^"']*)["']/) || ['', ''])[1];
if (name) fields[name] = value;
}
return fields;
}
function extractFormAction(html) {
const match = html.match(/id="ap_login_form"[^>]+action="([^"]+)"/);
if (match) return match[1].replace(/&amp;/g, '&');
const match2 = html.match(/name="signIn"[^>]+action="([^"]+)"/);
if (match2) return match2[1].replace(/&amp;/g, '&');
return null;
}
function buildFailureMessage(current, cookieJar) {
const keys = Object.keys(cookieJar).join(', ') || '(none)';
const hints = [];
if (current.body && (current.body.includes('captcha') || current.body.includes('CAPTCHA'))) {
hints.push('CAPTCHA が要求されています。少し待ってから再試行してください。');
}
if (current.body && current.body.includes('auth-mfa-form')) {
hints.push('MFA が要求されています。このスクリプトだけでは完了できません。');
}
if (current.body && current.body.includes('password') && current.body.includes('error')) {
hints.push('パスワードが間違っている可能性があります。');
}
const message = [
'認証に失敗しました。取得できた Cookie に認証トークンが含まれていません。',
`取得済み Cookie キー: ${keys}`,
...hints,
].join('\n');
const error = new Error(message);
error.code = 'ALEXA_AUTH_FAILED';
error.hints = hints;
return error;
}
async function fetchAlexaCookie(options) {
const opts = options || {};
const email = opts.email;
const password = opts.password;
const log = createLogger(opts.logger);
if (!email || !password) {
const error = new Error('email と password は必須です');
error.code = 'MISSING_CREDENTIALS';
throw error;
}
const state = createRequestState();
const request = state.request;
const cookieJar = state.cookieJar;
log('[1] ログインページ取得中...');
const page1 = await request(ALEXA_LOGIN_URL);
log(` Status: ${page1.status}, Cookies: ${Object.keys(cookieJar).join(', ')}`);
if (page1.status !== 200) {
const error = new Error(`ログインページ取得失敗: ${page1.status}`);
error.code = 'LOGIN_PAGE_FETCH_FAILED';
throw error;
}
const action = extractFormAction(page1.body);
const hidden = extractHiddenFields(page1.body);
if (!action) {
const error = new Error('ログインフォームが見つかりません。Amazon のログイン画面仕様が変わった可能性があります。');
error.code = 'LOGIN_FORM_NOT_FOUND';
throw error;
}
log(`[2] フォーム送信先: ${action}`);
log(` Hidden fields: ${Object.keys(hidden).join(', ')}`);
const formData = new URLSearchParams({
...hidden,
email: email,
password: password,
rememberMe: 'true',
}).toString();
log('[3] 認証送信中...');
const page2 = await request(action, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Referer': ALEXA_LOGIN_URL,
},
body: formData,
});
log(` Status: ${page2.status}`);
log(` Location: ${page2.headers.location || '(none)'}`);
log(` Cookies after login: ${Object.keys(cookieJar).join(', ')}`);
let current = page2;
let redirectCount = 0;
while (current.status >= 300 && current.status < 400 && current.headers.location && redirectCount < 10) {
const location = current.headers.location;
const nextUrl = location.startsWith('http') ? location : `https://www.amazon.co.jp${location}`;
log(`[${4 + redirectCount}] Redirect -> ${nextUrl.substring(0, 80)}...`);
current = await request(nextUrl);
log(` Status: ${current.status}, New Cookies: ${Object.keys(cookieJar).join(', ')}`);
redirectCount += 1;
}
const cookie = state.getCookieHeader();
const hasAlexaToken = cookie.includes('at-acbjp') || cookie.includes('session-token');
if (!hasAlexaToken) throw buildFailureMessage(current, cookieJar);
return {
cookie,
cookieLength: cookie.length,
cookieKeys: Object.keys(cookieJar),
};
}
function saveCookieToEnv(cookie, envPath) {
const targetEnvPath = envPath || path.join(__dirname, '.env');
fs.writeFileSync(targetEnvPath, `ALEXA_COOKIE=${cookie}\n`);
return targetEnvPath;
}
async function fetchAlexaCookieAndSave(options) {
const result = await fetchAlexaCookie(options);
const envPath = saveCookieToEnv(result.cookie, options && options.envPath);
return {
...result,
envPath,
};
}
module.exports = {
fetchAlexaCookie,
fetchAlexaCookieAndSave,
saveCookieToEnv,
};

369
alexa-api/auth4-web.js Normal file
View File

@@ -0,0 +1,369 @@
const express = require('express');
const { execFile } = require('child_process');
const { fetchAlexaCookieAndSave } = require('./auth4-core');
const app = express();
const PORT = process.env.AUTH4_WEB_PORT || 3678;
const LOCAL_ENV_PATH = __dirname + '/.env';
const DEFAULT_SSH_TARGET = process.env.ALEXA_DEPLOY_SSH_TARGET || 'keinafarm';
const DEFAULT_REMOTE_UPLOAD_PATH =
process.env.ALEXA_DEPLOY_REMOTE_UPLOAD_PATH || '/tmp/alexa-api.env';
const DEFAULT_DEPLOY_COMMAND =
process.env.ALEXA_DEPLOY_COMMAND ||
'sudo /usr/local/bin/alexa-cookie-deploy.sh /tmp/alexa-api.env';
app.use(express.urlencoded({ extended: false }));
function escapeHtml(value) {
return String(value || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function renderPage(options) {
const opts = options || {};
const email = escapeHtml(opts.email || '');
const logs = Array.isArray(opts.logs) ? opts.logs : [];
const result = opts.result || null;
const error = opts.error || '';
const statusClass = result ? 'ok' : (error ? 'error' : '');
const deploy = opts.deploy || {};
const deployTarget = escapeHtml(deploy.target || DEFAULT_SSH_TARGET);
const deployUploadPath = escapeHtml(deploy.remoteUploadPath || DEFAULT_REMOTE_UPLOAD_PATH);
const deployCommand = escapeHtml(deploy.deployCommand || DEFAULT_DEPLOY_COMMAND);
const deployLogs = Array.isArray(deploy.logs) ? deploy.logs : [];
const deployError = deploy.error || '';
const deployResult = deploy.result || null;
const messageHtml = result
? `<div class="notice ok">Cookie を更新しました。<br>保存先: <code>${escapeHtml(result.envPath)}</code><br>Cookie 長さ: ${escapeHtml(result.cookieLength)}</div>`
: (error ? `<div class="notice error">${escapeHtml(error).replace(/\n/g, '<br>')}</div>` : '');
const logHtml = logs.length
? `<pre>${escapeHtml(logs.join('\n'))}</pre>`
: '<pre>ここにログが表示されます。</pre>';
const deployMessageHtml = deployResult
? `<div class="notice ok">サーバー反映が完了しました。<br>転送先: <code>${deployTarget}:${deployUploadPath}</code></div>`
: (deployError ? `<div class="notice error">${escapeHtml(deployError).replace(/\n/g, '<br>')}</div>` : '');
const deployLogHtml = deployLogs.length
? `<pre>${escapeHtml(deployLogs.join('\n'))}</pre>`
: '<pre>ここに転送と再起動のログが表示されます。</pre>';
return `<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Alexa Cookie 更新</title>
<style>
:root {
--bg: #f3ede2;
--card: #fffaf2;
--ink: #1f2937;
--muted: #6b7280;
--line: #d9cdb7;
--accent: #1d6b57;
--accent-strong: #114e3f;
--danger: #a63b2b;
--danger-bg: #fff1ee;
--ok-bg: #edf8f3;
--shadow: 0 20px 60px rgba(76, 56, 31, 0.12);
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "Hiragino Sans", "Yu Gothic", sans-serif;
color: var(--ink);
background:
radial-gradient(circle at top left, rgba(29, 107, 87, 0.14), transparent 28%),
radial-gradient(circle at right, rgba(177, 107, 32, 0.12), transparent 24%),
linear-gradient(180deg, #f8f3ea 0%, var(--bg) 100%);
min-height: 100vh;
padding: 32px 16px;
}
.wrap {
max-width: 760px;
margin: 0 auto;
}
.card {
background: color-mix(in srgb, var(--card) 92%, white 8%);
border: 1px solid rgba(217, 205, 183, 0.9);
border-radius: 24px;
box-shadow: var(--shadow);
padding: 28px;
backdrop-filter: blur(8px);
}
h1 {
margin: 0 0 10px;
font-size: clamp(28px, 4vw, 42px);
line-height: 1.05;
letter-spacing: -0.03em;
}
p {
margin: 0 0 20px;
color: var(--muted);
line-height: 1.7;
}
form {
display: grid;
gap: 16px;
margin-top: 24px;
}
.section {
margin-top: 28px;
padding-top: 24px;
border-top: 1px solid rgba(217, 205, 183, 0.9);
}
label {
display: grid;
gap: 8px;
font-weight: 700;
}
input {
width: 100%;
border: 1px solid var(--line);
border-radius: 14px;
padding: 14px 15px;
font: inherit;
background: #fff;
}
input:focus {
outline: 2px solid rgba(29, 107, 87, 0.18);
border-color: var(--accent);
}
button {
border: 0;
border-radius: 999px;
padding: 14px 22px;
font: inherit;
font-weight: 700;
color: white;
background: linear-gradient(135deg, var(--accent) 0%, var(--accent-strong) 100%);
cursor: pointer;
justify-self: start;
}
.notice {
margin: 20px 0 0;
padding: 14px 16px;
border-radius: 14px;
line-height: 1.6;
border: 1px solid transparent;
}
.notice.ok {
background: var(--ok-bg);
border-color: rgba(29, 107, 87, 0.2);
}
.notice.error {
background: var(--danger-bg);
border-color: rgba(166, 59, 43, 0.2);
color: var(--danger);
}
.meta {
margin-top: 22px;
font-size: 14px;
color: var(--muted);
}
pre {
margin: 14px 0 0;
padding: 16px;
border-radius: 16px;
background: #1d2430;
color: #eef2f7;
overflow: auto;
line-height: 1.55;
min-height: 160px;
white-space: pre-wrap;
word-break: break-word;
}
code {
font-family: "SFMono-Regular", Consolas, monospace;
}
.hint {
margin-top: 10px;
font-size: 14px;
color: var(--muted);
line-height: 1.6;
}
</style>
</head>
<body>
<div class="wrap">
<div class="card ${statusClass}">
<h1>Alexa Cookie 更新</h1>
<p>メールアドレスとパスワードをその場で入力して、<code>auth4.js</code> と同じ認証フローで <code>.env</code> を更新します。入力値は保存しません。</p>
<form method="post" action="/login">
<label>
Amazon メールアドレス
<input type="email" name="email" value="${email}" autocomplete="username" required>
</label>
<label>
Amazon パスワード
<input type="password" name="password" autocomplete="current-password" required>
</label>
<button type="submit">Cookie を更新する</button>
</form>
${messageHtml}
<div class="meta">ローカル専用の簡易 GUI です。CAPTCHA や MFA が出た場合は失敗ログを表示します。</div>
${logHtml}
<div class="section">
<h1>サーバー反映</h1>
<p>更新済みの <code>.env</code> を一時パスへ転送し、<code>sudoers</code> で許可した専用スクリプトだけを実行します。SSH の接続先は必要に応じて変えてください。</p>
<form method="post" action="/deploy">
<label>
SSH 接続先
<input type="text" name="target" value="${deployTarget}" required>
</label>
<label>
リモート一時アップロード先
<input type="text" name="remoteUploadPath" value="${deployUploadPath}" required>
</label>
<label>
実行する専用コマンド
<input type="text" name="deployCommand" value="${deployCommand}" required>
</label>
<button type="submit">サーバーへ反映する</button>
</form>
${deployMessageHtml}
<div class="hint">このPCの SSH 設定では <code>keinafarm</code> が使えます。初回はサーバー側に <code>/usr/local/bin/alexa-cookie-deploy.sh</code> と <code>sudoers</code> 設定が必要です。</div>
${deployLogHtml}
</div>
</div>
</div>
</body>
</html>`;
}
function runCommand(command, args) {
return new Promise((resolve, reject) => {
execFile(command, args, { maxBuffer: 1024 * 1024 }, function(error, stdout, stderr) {
if (error) {
error.stdout = stdout || '';
error.stderr = stderr || '';
reject(error);
return;
}
resolve({
stdout: stdout || '',
stderr: stderr || '',
});
});
});
}
function buildDeployState(body, extra) {
const payload = body || {};
const override = extra || {};
return {
target: override.target || payload.target || DEFAULT_SSH_TARGET,
remoteUploadPath: override.remoteUploadPath || payload.remoteUploadPath || DEFAULT_REMOTE_UPLOAD_PATH,
deployCommand: override.deployCommand || payload.deployCommand || DEFAULT_DEPLOY_COMMAND,
logs: override.logs || [],
error: override.error || '',
result: override.result || null,
};
}
app.get('/', function(req, res) {
res.type('html').send(renderPage({
deploy: buildDeployState({}),
}));
});
app.post('/login', async function(req, res) {
const email = (req.body && req.body.email) || '';
const password = (req.body && req.body.password) || '';
const logs = [];
const logger = function(message) {
logs.push(message);
};
if (!email || !password) {
res.status(400).type('html').send(renderPage({
email,
error: 'メールアドレスとパスワードは必須です。',
logs,
deploy: buildDeployState({}),
}));
return;
}
try {
const result = await fetchAlexaCookieAndSave({ email, password, logger });
logs.push('');
logs.push('==============================================');
logs.push('認証成功');
logs.push(`保存先: ${result.envPath}`);
logs.push(`Cookie 長さ: ${result.cookieLength}`);
res.type('html').send(renderPage({
email,
result,
logs,
deploy: buildDeployState({}),
}));
} catch (error) {
logs.push('');
logs.push('[ERROR] ' + error.message);
res.status(500).type('html').send(renderPage({
email,
error: error.message,
logs,
deploy: buildDeployState({}),
}));
}
});
app.post('/deploy', async function(req, res) {
const deploy = buildDeployState(req.body);
const logs = [];
if (!deploy.target || !deploy.remoteUploadPath || !deploy.deployCommand) {
res.status(400).type('html').send(renderPage({
deploy: buildDeployState(req.body, {
logs,
error: 'SSH 接続先、一時アップロード先、専用コマンドは必須です。',
}),
}));
return;
}
try {
logs.push('[1] .env をサーバーの一時パスへ転送中...');
logs.push(`scp ${LOCAL_ENV_PATH} ${deploy.target}:${deploy.remoteUploadPath}`);
const scpResult = await runCommand('scp', [LOCAL_ENV_PATH, `${deploy.target}:${deploy.remoteUploadPath}`]);
if (scpResult.stdout.trim()) logs.push(scpResult.stdout.trim());
if (scpResult.stderr.trim()) logs.push(scpResult.stderr.trim());
logs.push('');
logs.push('[2] 専用デプロイスクリプトを実行中...');
logs.push(`ssh ${deploy.target} ${deploy.deployCommand}`);
const sshResult = await runCommand('ssh', [deploy.target, deploy.deployCommand]);
if (sshResult.stdout.trim()) logs.push(sshResult.stdout.trim());
if (sshResult.stderr.trim()) logs.push(sshResult.stderr.trim());
logs.push('');
logs.push('反映完了');
res.type('html').send(renderPage({
deploy: buildDeployState(req.body, {
logs,
result: { ok: true },
}),
}));
} catch (error) {
if (error.stdout && error.stdout.trim()) logs.push(error.stdout.trim());
if (error.stderr && error.stderr.trim()) logs.push(error.stderr.trim());
logs.push('');
logs.push('[ERROR] ' + error.message);
res.status(500).type('html').send(renderPage({
deploy: buildDeployState(req.body, {
logs,
error: error.stderr || error.message,
}),
}));
}
});
app.listen(PORT, '127.0.0.1', function() {
console.log('[INFO] auth4-web listening on http://127.0.0.1:' + PORT);
});

View File

@@ -1,187 +1,28 @@
/** /**
* auth4.js - Amazon Japan OpenID フローを正しく再現するカスタム認証スクリプト * auth4.js - CLI 版の Alexa Cookie 更新
* alexa-cookie2 の古いエンドポイント問題を回避して直接フォームを処理する
*/ */
const https = require('https');
const fs = require('fs');
const path = require('path');
const EMAIL = process.env.AMAZON_EMAIL; const EMAIL = process.env.AMAZON_EMAIL;
const PASSWORD = process.env.AMAZON_PASSWORD; const PASSWORD = process.env.AMAZON_PASSWORD;
const { fetchAlexaCookieAndSave } = require('./auth4-core');
if (!EMAIL || !PASSWORD) { if (!EMAIL || !PASSWORD) {
console.error('[ERROR] 環境変数 AMAZON_EMAIL と AMAZON_PASSWORD を設定してください'); console.error('[ERROR] 環境変数 AMAZON_EMAIL と AMAZON_PASSWORD を設定してください');
process.exit(1); process.exit(1);
} }
const ALEXA_LOGIN_URL =
'https://www.amazon.co.jp/ap/signin?' +
new URLSearchParams({
'openid.assoc_handle': 'amzn_dp_project_dee_jp',
'openid.mode': 'checkid_setup',
'openid.ns': 'http://specs.openid.net/auth/2.0',
'openid.claimed_id': 'http://specs.openid.net/auth/2.0/identifier_select',
'openid.identity': 'http://specs.openid.net/auth/2.0/identifier_select',
'openid.return_to': 'https://alexa.amazon.co.jp/api/apps/v1/token',
'pageId': 'amzn_dp_project_dee_jp',
}).toString();
const USER_AGENT =
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Safari/537.36';
let cookieJar = {};
function setCookies(setCookieHeaders) {
if (!setCookieHeaders) return;
const headers = Array.isArray(setCookieHeaders) ? setCookieHeaders : [setCookieHeaders];
for (const h of headers) {
const [kv] = h.split(';');
const [k, v] = kv.trim().split('=');
if (k && v !== undefined) cookieJar[k.trim()] = v.trim();
}
}
function getCookieHeader() {
return Object.entries(cookieJar)
.map(([k, v]) => `${k}=${v}`)
.join('; ');
}
function request(url, options = {}) {
return new Promise((resolve, reject) => {
const parsed = new URL(url);
const reqOpts = {
hostname: parsed.hostname,
path: parsed.pathname + parsed.search,
method: options.method || 'GET',
headers: {
'User-Agent': USER_AGENT,
'Accept-Language': 'ja-JP,ja;q=0.9',
'Accept': 'text/html,application/xhtml+xml,*/*;q=0.8',
'Cookie': getCookieHeader(),
...(options.headers || {}),
},
};
const req = https.request(reqOpts, (res) => {
setCookies(res.headers['set-cookie']);
let body = '';
res.on('data', (d) => (body += d));
res.on('end', () => {
resolve({ status: res.statusCode, headers: res.headers, body });
});
});
req.on('error', reject);
if (options.body) req.write(options.body);
req.end();
});
}
// HTML の hidden フィールドを抽出
function extractHiddenFields(html) {
const fields = {};
const re = /<input[^>]+type=["']?hidden["']?[^>]*>/gi;
let match;
while ((match = re.exec(html)) !== null) {
const tag = match[0];
const name = (tag.match(/name=["']([^"']+)["']/) || [])[1];
const value = (tag.match(/value=["']([^"']*)["']/) || ['', ''])[1];
if (name) fields[name] = value;
}
return fields;
}
// フォームの action URL を抽出
function extractFormAction(html) {
const m = html.match(/id="ap_login_form"[^>]+action="([^"]+)"/);
if (m) return m[1].replace(/&amp;/g, '&');
const m2 = html.match(/name="signIn"[^>]+action="([^"]+)"/);
if (m2) return m2[1].replace(/&amp;/g, '&');
return null;
}
async function main() { async function main() {
console.log('[1] ログインページ取得中...'); const result = await fetchAlexaCookieAndSave({
const page1 = await request(ALEXA_LOGIN_URL);
console.log(` Status: ${page1.status}, Cookies: ${Object.keys(cookieJar).join(', ')}`);
if (page1.status !== 200) {
console.error(`[ERROR] ログインページ取得失敗: ${page1.status}`);
process.exit(1);
}
// フォーム情報を抽出
const action = extractFormAction(page1.body);
const hidden = extractHiddenFields(page1.body);
if (!action) {
console.error('[ERROR] ログインフォームが見つかりません。HTMLを確認します:');
console.error(page1.body.substring(0, 500));
process.exit(1);
}
console.log(`[2] フォーム送信先: ${action}`);
console.log(` Hidden fields: ${Object.keys(hidden).join(', ')}`);
// フォームデータ構築
const formData = new URLSearchParams({
...hidden,
email: EMAIL, email: EMAIL,
password: PASSWORD, password: PASSWORD,
rememberMe: 'true', logger: console.log,
}).toString();
console.log('[3] 認証送信中...');
const page2 = await request(action, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Referer': ALEXA_LOGIN_URL,
},
body: formData,
}); });
console.log(` Status: ${page2.status}`);
console.log(` Location: ${page2.headers.location || '(none)'}`);
console.log(` Cookies after login: ${Object.keys(cookieJar).join(', ')}`);
// リダイレクトをたどる
let current = page2;
let redirectCount = 0;
while (current.status >= 300 && current.status < 400 && current.headers.location && redirectCount < 10) {
const loc = current.headers.location;
const nextUrl = loc.startsWith('http') ? loc : `https://www.amazon.co.jp${loc}`;
console.log(`[${4 + redirectCount}] Redirect → ${nextUrl.substring(0, 80)}...`);
current = await request(nextUrl);
console.log(` Status: ${current.status}, New Cookies: ${Object.keys(cookieJar).join(', ')}`);
redirectCount++;
}
// 成功判定: at-acbjp または session-token が含まれているか
const cookie = getCookieHeader();
const hasAlexaToken = cookie.includes('at-acbjp') || cookie.includes('session-token');
if (!hasAlexaToken) {
console.error('[ERROR] 認証に失敗しました。取得できたCookieに認証トークンが含まれていません。');
console.error('取得済みCookieキー:', Object.keys(cookieJar).join(', '));
if (current.body.includes('captcha') || current.body.includes('CAPTCHA')) {
console.error('※ CAPTCHA が要求されています。しばらく待ってから再試行してください。');
}
if (current.body.includes('password') && current.body.includes('error')) {
console.error('※ パスワードが間違っている可能性があります。');
}
process.exit(1);
}
// .env に保存
const envPath = path.join(__dirname, '.env');
fs.writeFileSync(envPath, `ALEXA_COOKIE=${cookie}\n`);
console.log('\n=============================================='); console.log('\n==============================================');
console.log(' 認証成功!'); console.log(' 認証成功!');
console.log('=============================================='); console.log('==============================================');
console.log(`.env を保存しました: ${envPath}`); console.log(`.env を保存しました: ${result.envPath}`);
console.log(`Cookie 長さ: ${cookie.length} 文字`); console.log(`Cookie 長さ: ${result.cookieLength} 文字`);
} }
main().catch((err) => { main().catch((err) => {

View File

@@ -4,7 +4,9 @@
"description": "Alexa TTS API server for Windmill integration", "description": "Alexa TTS API server for Windmill integration",
"main": "server.js", "main": "server.js",
"scripts": { "scripts": {
"start": "node server.js" "start": "node server.js",
"auth": "node auth4.js",
"auth:web": "node auth4-web.js"
}, },
"dependencies": { "dependencies": {
"express": "^4.18.0" "express": "^4.18.0"

View File

@@ -37,6 +37,7 @@ function httpsRequest(path, options, extraCookies) {
extraCookies = extraCookies || ''; extraCookies = extraCookies || '';
return new Promise(function(resolve, reject) { return new Promise(function(resolve, reject) {
var allCookies = ALEXA_COOKIE + (extraCookies ? '; ' + extraCookies : ''); var allCookies = ALEXA_COOKIE + (extraCookies ? '; ' + extraCookies : '');
// bodyBuf はバイト列変換(マルチバイト文字に対応)
var bodyBuf = options.body ? Buffer.from(options.body, 'utf8') : null; var bodyBuf = options.body ? Buffer.from(options.body, 'utf8') : null;
var reqOpts = { var reqOpts = {
hostname: ALEXA_HOST, hostname: ALEXA_HOST,
@@ -47,7 +48,8 @@ function httpsRequest(path, options, extraCookies) {
'Accept': 'application/json, text/plain, */*', 'Accept': 'application/json, text/plain, */*',
'Accept-Language': 'ja-JP,ja;q=0.9', 'Accept-Language': 'ja-JP,ja;q=0.9',
'Cookie': allCookies, 'Cookie': allCookies,
}, bodyBuf ? { 'Content-Length': bodyBuf.length } : {}, options.headers || {}), // Content-Length は送らないtest_tts.js で動作実績あり、Amazonが自動判定
}, options.headers || {}),
}; };
var req = https.request(reqOpts, function(res) { var req = https.request(reqOpts, function(res) {
var body = ''; var body = '';
@@ -128,6 +130,8 @@ app.post('/speak', async function(req, res) {
console.log(' -> ' + target.accountName + ' (type=' + target.deviceType + ', serial=' + target.serialNumber + ')'); console.log(' -> ' + target.accountName + ' (type=' + target.deviceType + ', serial=' + target.serialNumber + ')');
// ★ 重要: sequenceJson の non-ASCII日本語等を \uXXXX エスケープに変換してから送る
// raw UTF-8 のまま送ると Amazon 側でフィルタリングされ日本語が発話されない(解決済み 2026-03-03
var sequenceObj = { var sequenceObj = {
'@type': 'com.amazon.alexa.behaviors.model.Sequence', '@type': 'com.amazon.alexa.behaviors.model.Sequence',
startNode: { startNode: {
@@ -138,18 +142,24 @@ app.post('/speak', async function(req, res) {
deviceSerialNumber: target.serialNumber, deviceSerialNumber: target.serialNumber,
customerId: customerId, customerId: customerId,
locale: 'ja-JP', locale: 'ja-JP',
textToSpeak: text, textToSpeak: text
speakType: 'ssml'
}, },
}, },
}; };
var rawSequenceJson = JSON.stringify(sequenceObj).replace(
/[\u0080-\uffff]/g,
function(c) { return '\\u' + ('0000' + c.charCodeAt(0).toString(16)).slice(-4); }
);
var bodyStr = JSON.stringify({ var bodyStr = JSON.stringify({
behaviorId: 'PREVIEW', behaviorId: 'PREVIEW',
sequenceJson: JSON.stringify(sequenceObj), sequenceJson: rawSequenceJson,
status: 'ENABLED', status: 'ENABLED',
}); });
console.log('[DEBUG] textToSpeak:', text);
var ttsRes = await httpsRequest('/api/behaviors/preview', { var ttsRes = await httpsRequest('/api/behaviors/preview', {
method: 'POST', method: 'POST',
headers: { headers: {
@@ -161,6 +171,9 @@ app.post('/speak', async function(req, res) {
body: bodyStr, body: bodyStr,
}, 'csrf=' + csrfToken); }, 'csrf=' + csrfToken);
// Amazonからのレスポンスをログ出力
console.log('[DEBUG] Alexa API response: ' + ttsRes.status + ' body=' + ttsRes.body.substring(0, 200));
if (ttsRes.status === 200 || ttsRes.status === 202) { if (ttsRes.status === 200 || ttsRes.status === 202) {
console.log(' [OK] TTS sent to ' + target.accountName); console.log(' [OK] TTS sent to ' + target.accountName);
res.json({ ok: true, device: target.accountName, text: text }); res.json({ ok: true, device: target.accountName, text: text });

View File

@@ -61,7 +61,7 @@ async function main() {
}); });
// プレハブを探す // プレハブを探す
const target = devices.find(d => d.serialNumber === 'G0922H085165007R'); const target = devices.find(d => d.serialNumber === 'G0922H08525302K5'); // オフィスの右エコー(以前成功したデバイス)
console.log('\nTarget device:', target ? `${target.accountName}` : 'NOT FOUND'); console.log('\nTarget device:', target ? `${target.accountName}` : 'NOT FOUND');
if (!target) { process.exit(1); } if (!target) { process.exit(1); }
@@ -72,7 +72,7 @@ async function main() {
const customerId = bootstrap.authentication?.customerId; const customerId = bootstrap.authentication?.customerId;
console.log(' Customer ID:', customerId); console.log(' Customer ID:', customerId);
// 3. TTSリクエスト // 3. TTSリクエスト新Cookie + Alexa.Speak + locale:'ja-JP' + 日本語テキスト)
const sequenceObj = { const sequenceObj = {
'@type': 'com.amazon.alexa.behaviors.model.Sequence', '@type': 'com.amazon.alexa.behaviors.model.Sequence',
startNode: { startNode: {
@@ -83,19 +83,30 @@ async function main() {
deviceSerialNumber: target.serialNumber, deviceSerialNumber: target.serialNumber,
customerId: customerId, customerId: customerId,
locale: 'ja-JP', locale: 'ja-JP',
textToSpeak: 'テストです。聞こえますか', textToSpeak: '\u3053\u308c\u306f\u65e5\u672c\u8a9e\u306e\u30c6\u30b9\u30c8\u3067\u3059', // 「これは日本語のテストです」
}, },
}, },
}; };
// non-ASCII を \uXXXX に強制エスケープ
// Amazon のパーサーが sequenceJson 内の raw UTF-8 を処理できない場合の回避策
const rawSequenceJson = JSON.stringify(sequenceObj).replace(
/[\u0080-\uffff]/g,
c => '\\u' + ('0000' + c.charCodeAt(0).toString(16)).slice(-4)
);
const bodyObj = { const bodyObj = {
behaviorId: 'PREVIEW', behaviorId: 'PREVIEW',
sequenceJson: JSON.stringify(sequenceObj), sequenceJson: rawSequenceJson,
status: 'ENABLED', status: 'ENABLED',
}; };
const body = JSON.stringify(bodyObj); const body = JSON.stringify(bodyObj);
console.log('\n[3] TTS送信...'); console.log('\n[3] TTS送信...');
// 送信内容確認textToSpeakの部分が\uXXXXエスケープになっているか
const ttsIdx = body.indexOf('textToSpeak');
console.log(' textToSpeak部分:', body.substring(ttsIdx, ttsIdx + 80));
const ttsRes = await makeRequest('https://alexa.amazon.co.jp/api/behaviors/preview', { const ttsRes = await makeRequest('https://alexa.amazon.co.jp/api/behaviors/preview', {
method: 'POST', method: 'POST',

1
butler.pid Normal file
View File

@@ -0,0 +1 @@
34292

View File

@@ -21,6 +21,7 @@
--- ---
## 1. 機能概要 ## 1. 機能概要
### 目的 ### 目的

View File

@@ -0,0 +1,661 @@
# マスタードキュメント - Alexa TTS API 編
> **最終更新**: 2026-03-04
> **対象システム**: windmill.keinafarm.netワークスペース: admins
> **目的**: このドキュメントだけで Alexa TTS API の全容を把握し、作業を継続できること
> **関連ドキュメント**: `docs/31_Alexa_Cookie更新GUI運用.md`
---
## 目次
1. [機能概要](#1-機能概要)
2. [システム構成](#2-システム構成)
3. [ファイル構成](#3-ファイル構成)
4. [Windmillスクリプト仕様](#4-windmillスクリプト仕様)
5. [APIサーバー仕様](#5-apiサーバー仕様)
6. [Alexa API の仕組み(重要な知識)](#6-alexa-api-の仕組み重要な知識)
7. [認証・Cookie管理](#7-認証cookie管理)
8. [デプロイ手順](#8-デプロイ手順)
9. [デバイス一覧](#9-デバイス一覧)
10. [運用手順・コマンド集](#10-運用手順コマンド集)
11. [既知の問題・落とし穴](#11-既知の問題落とし穴)
12. [ソースファイル索引](#12-ソースファイル索引)
13. [実装の経緯(試行錯誤記録)](#13-実装の経緯試行錯誤記録)
14. [更新履歴](#14-更新履歴)
---
## 1. 機能概要
### 目的
Windmill のワークフローから、家の各部屋に設置した Amazon Echo デバイスに対して、任意の日本語テキストを読み上げさせる。
### ユーザーフロー
```
Windmill ワークフロー
└→ POST http://alexa_api:3500/speak
└→ alexa-api サーバーDockerコンテナ
└→ HTTPS: alexa.amazon.co.jp/api/behaviors/preview
└→ Amazon サーバーが Echo デバイスに指示
└→ Echo デバイスが日本語で読み上げる
```
### 現在の状態
**✅ 完全動作中2026-03-04 時点)**
- ローカルPCからもサーバーのDockerコンテナからも、日本語テキストの読み上げが動作する
- 解決の鍵: `sequenceJson` 内の日本語文字を `\uXXXX` 形式にエスケープして送信する
- 補足: `u/admin/alexa_speak` を API 更新した直後、Windmill UI の入力欄が即時更新されない場合がある(後述の運用回避策を適用)
---
## 2. システム構成
```
[ローカルPC (Windows)]
c:\Users\akira\Develop\windmill_workflow\alexa-api\
├── 開発・編集
├── auth4.js でCookie取得ローカルのみ実行可能
└── gitea でサーバーと同期push は scp を使う)
[VPSサーバー (keinafarm.net)]
/home/claude/alexa-api/ ← git とは別にコピーして管理
├── server.js
├── Dockerfile
├── docker-compose.yml
└── .env ALEXA_COOKIE を保管)
[Docker コンテナ: alexa_api]
├── ネットワーク: windmill_windmill-internal
├── ポート: 3500外部非公開
└── Windmill ワーカーから http://alexa_api:3500 でアクセス
[Windmill]
スクリプト: u/admin/alexa_speak
└→ http://alexa_api:3500/speak を呼び出す
```
### ネットワーク設計のポイント
- `alexa_api` コンテナは外部に公開しない(セキュリティ)
- Windmill ワーカーと同じ Docker 内部ネットワーク `windmill_windmill-internal` に接続
- Windmill から `http://alexa_api:3500` でアクセス可能
### Cookie 更新の権限設計
- `akira``windmill` への自由な切り替え権限は与えない
- 代わりに `sudoers``/usr/local/bin/alexa-cookie-deploy.sh /tmp/alexa-api.env` だけを許可する
- ローカル GUI は `.env``/tmp/alexa-api.env` へ転送したあと、この専用スクリプトだけを `sudo` で実行する
- 専用スクリプト自身は root で `.env` 反映と `docker compose restart` を完了する
- これにより Cookie 更新だけを安全寄りに GUI 化できる
---
## 3. ファイル構成
| ファイル | 場所 | 役割 | 備考 |
|---------|------|------|------|
| `server.js` | `alexa-api/` | Express API サーバー本体 | 本番コード。変更したらビルド・再デプロイが必要 |
| `Dockerfile` | `alexa-api/` | Docker イメージ定義 | node:20-alpine ベース |
| `docker-compose.yml` | `alexa-api/` | コンテナ起動設定 | windmill_windmill-internal に接続 |
| `package.json` | `alexa-api/` | npm 依存関係 | 本番は express のみ |
| `.env.example` | `alexa-api/` | 環境変数テンプレート | `ALEXA_COOKIE=xxx` の形式 |
| `.env` | `alexa-api/`.gitignore 対象) | 実際の Cookie 保管 | Git にコミットしない |
| `auth4.js` | `alexa-api/` | Amazon 認証・Cookie 取得スクリプト | CLI版。ローカルのみで実行 |
| `auth4-web.js` | `alexa-api/` | Cookie 更新 GUI | ブラウザで認証し、サーバー反映まで実行可能 |
| `auth4-core.js` | `alexa-api/` | Cookie 更新の共通ロジック | CLI版とGUI版で共通利用 |
| `alexa-cookie-deploy.sh` | `alexa-api/` | サーバー側専用反映スクリプト | `/tmp/alexa-api.env` を本番 `.env` に反映し、root で再起動 |
| `alexa-cookie-deploy.sudoers` | `alexa-api/` | sudoers 設定例 | `akira` から専用反映スクリプトだけ実行許可 |
| `auth.js` / `auth2.js` / `auth3.js` | `alexa-api/` | auth4.js の旧バージョン | 参考用。実際は auth4.js を使う |
| `test_tts.js` | `alexa-api/` | ローカルテスト用スクリプト | `.env` を読んで直接 alexa.amazon.co.jp を叩く。テスト対象デバイスはシリアル `G0922H08525302K5`オフィスの右エコーにハードコード。TABLET は一覧表示から除外。 |
---
## 4. Windmillスクリプト仕様
### スクリプトパス
```
u/admin/alexa_speak
```
### スクリプト本体TypeScript / Bun
```typescript
export async function main(device: string, text: string) {
const res = await fetch("http://alexa_api:3500/speak", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ device, text }),
});
if (!res.ok) throw new Error("alexa-api error " + res.status);
return res.json();
}
```
### スキーマ
```json
{
"type": "object",
"required": ["device", "text"],
"properties": {
"device": {
"type": "string",
"description": "デバイス名またはシリアル番号"
},
"text": {
"type": "string",
"description": "読み上げるテキスト"
}
}
}
```
### 呼び出し例
```typescript
// デバイス名で指定
await main("オフィスの右エコー", "来客がありました");
// シリアル番号で指定(確実)
await main("G0922H08525302K5", "来客がありました");
```
---
## 5. APIサーバー仕様
### エンドポイント一覧
| メソッド | パス | 説明 |
|---------|------|------|
| `POST` | `/speak` | テキスト読み上げ |
| `GET` | `/devices` | デバイス一覧取得 |
| `GET` | `/health` | ヘルスチェック |
### POST /speak
**リクエスト**:
```json
{
"device": "オフィスの右エコー",
"text": "読み上げる日本語テキスト"
}
```
- `device`: デバイス名(日本語)またはシリアル番号。部分一致も可能
- `text`: 読み上げるテキスト日本語OK
**レスポンス(成功)**:
```json
{
"ok": true,
"device": "オフィスの右エコー",
"text": "読み上げる日本語テキスト"
}
```
- Amazon は **200 または 202** を返すどちらも成功として扱う。202 は非同期処理を示す)
**レスポンス(失敗)**:
```json
{
"error": "デバイス \"xxxxx\" が見つかりません",
"available": "プレハブ, リビングエコー1, ..."
}
```
### GET /health
```json
{ "ok": true, "cookieLength": 1234 }
```
### GET /devices
```json
[
{ "name": "オフィスの右エコー", "type": "A4ZXE0RM7LQ7A", "serial": "G0922H08525302K5", "online": true, "family": "ECHO" },
...
]
```
- `force=true` で**常にキャッシュを無効化**して最新一覧を取得する(`/speak` の5分キャッシュとは独立
- TABLET や Alexa アプリなど、Echo 以外のデバイスも含む全デバイスを返す
### デバイス検索ロジックfindDevice
`/speak``device` パラメータは以下の優先順位で検索する:
1. **シリアル番号完全一致**`serialNumber === device`
2. **アカウント名完全一致**(大文字小文字を無視)
3. **アカウント名部分一致**`includes()` 、大文字小文字を無視)
```javascript
// 例: "右エコー" でも "オフィスの右エコー" を見つけられる
```
### キャッシュ仕様
| 対象 | TTL | 備考 |
|------|-----|------|
| `customerId` | サーバー再起動まで永続 | Bootstrap API から取得 |
| デバイス一覧(`/speak` 経由) | 5分 | 期限切れ後は自動更新 |
| デバイス一覧(`/devices` 経由) | なし(毎回強制取得) | 診断・確認用途 |
---
## 6. Alexa API の仕組み(重要な知識)
### 直接 API 実装の理由
`alexa-remote2` ライブラリは、取得済みの Cookie 文字列を受け付けず内部で再認証しようとして失敗するため、使用しない。すべて自前で HTTPS リクエストを組み立てる。
### API 呼び出しシーケンス
```
1. GET /api/language
→ Set-Cookie: csrf=XXXXX を取得(毎リクエストごとに必要)
2. GET /api/bootstrap
→ customerId を取得(キャッシュ: サーバー起動中は永続)
→ customerId = "A1AE8HXD8IJ61L"
3. GET /api/devices-v2/device?cached=false
→ デバイス一覧取得5分キャッシュ
4. POST /api/behaviors/preview
→ シーケンス JSON を送信して読み上げ実行
```
### サーバー起動時の自動初期化
`app.listen()` の直後に非同期で初期化処理を実行する:
1. `getCustomerId()` を呼び出して `customerId` をキャッシュ(成功すると `[INFO] Customer ID: xxx` をログ出力)
2. `getDevices()` を呼び出してデバイス一覧をキャッシュ5分 TTL
3. `deviceType``A4ZXE` または `ASQZWP` で始まるデバイスEcho 系)のみをログ出力
**失敗してもサーバーは起動し続ける**`[WARN] Startup init failed:` と出力して続行)。ただし Cookie が無効な場合、その後の `/speak` リクエストも全て失敗する。
### POST /api/behaviors/preview のリクエスト構造
```json
{
"behaviorId": "PREVIEW",
"sequenceJson": "<エスケープ済みJSON文字列>",
"status": "ENABLED"
}
```
**`sequenceJson` の中身**JSON文字列化 + `\uXXXX` エスケープ後):
```json
{
"@type": "com.amazon.alexa.behaviors.model.Sequence",
"startNode": {
"@type": "com.amazon.alexa.behaviors.model.OpaquePayloadOperationNode",
"type": "Alexa.Speak",
"operationPayload": {
"deviceType": "A4ZXE0RM7LQ7A",
"deviceSerialNumber": "G0922H08525302K5",
"customerId": "A1AE8HXD8IJ61L",
"locale": "ja-JP",
"textToSpeak": "読み上げるテキスト"
}
}
}
```
### ⚠️ 最重要ポイント: `\uXXXX` エスケープ
```javascript
// ★ これをしないと日本語が発話されない!
var rawSequenceJson = JSON.stringify(sequenceObj).replace(
/[\u0080-\uffff]/g,
function(c) { return '\\u' + ('0000' + c.charCodeAt(0).toString(16)).slice(-4); }
);
```
**なぜ必要か**: `sequenceJson` に raw UTF-8 の日本語文字が含まれていると、Amazon のパーサーが日本語 Unicode 文字をフィルタリングして除去してしまい、発話されない。`\uXXXX` 形式の JSON エスケープシーケンスに変換することで回避できる。
### ヘッダー要件
```
Content-Type: application/json
csrf: <CSRFトークン> ← ヘッダーに必要
Referer: https://alexa.amazon.co.jp/spa/index.html
Origin: https://alexa.amazon.co.jp
Cookie: <ALEXA_COOKIE>; csrf=<CSRFトークン> ← Cookieにも必要
```
- CSRF トークンはヘッダー(`csrf:`)と Cookie`csrf=`)の **両方に必要**
- `Content-Length` は不要Amazon が自動判定)
### locale パラメータ
| 値 | 動作 |
|----|------|
| `"ja-JP"` | ✅ 日本語で発話(`\uXXXX` エスケープが前提) |
| `""` (空文字) | 英語のみ発話。日本語は除去される |
| locale なし | 英語音声として扱われる |
---
## 7. 認証・Cookie管理
### Cookie の役割
Amazon Alexa の非公式 API は Cookie 認証を使用する。Alexa アプリのログイン状態を模倣する。
### Cookie の取得方法auth4.js
**ローカル PCWindowsでのみ実行可能**Amazon のログインフローにブラウザーリダイレクトが必要なため)。
```bash
# alexa-api ディレクトリで実行
cd alexa-api
AMAZON_EMAIL="メールアドレス" AMAZON_PASSWORD="パスワード" node auth4.js
```
成功すると `alexa-api/.env` が生成・更新される。
### auth4.js のログインフロー
1. `GET https://www.amazon.co.jp/ap/signin?openid.assoc_handle=amzn_dp_project_dee_jp`
- `openid.assoc_handle: 'amzn_dp_project_dee_jp'`**Alexa Japan 専用のハンドル**他のAmazonサービスとは異なる
- `openid.return_to: 'https://alexa.amazon.co.jp/api/apps/v1/token'` にリダイレクト先を指定
2. hidden フィールドanti-csrftoken-a2z, appActionToken, workflowState 等)を HTML から抽出
3. POST でメール/パスワードを `rememberMe: 'true'` と一緒に送信長期Cookie取得のため重要
4. 3xx リダイレクトを最大10回たどる`alexa.amazon.co.jp/api/apps/v1/token` 等)
5. 取得した Cookieat-acbjp, sess-at-acbjp, sst-acbjp, session-token 等)を `.env` に保存
**成功判定**: Cookie に `at-acbjp` または `session-token` が含まれているかで判定。
**失敗時のエラー検出**:
- CAPTCHA が要求されている場合: `※ CAPTCHA が要求されています。しばらく待ってから再試行してください。`
- パスワードが間違っている場合: `※ パスワードが間違っている可能性があります。`
### Cookie の有効期限
数日〜数週間で期限切れになる。期限切れの症状: `/health` を叩くと Cookie 長は正常だが、`/speak` が 400 や 403 を返す。
---
## 8. デプロイ手順
### A. コード変更時のデプロイ(ビルドが必要)
`server.js` / `Dockerfile` / `package.json` を変更した場合:
```bash
# Step 1: ローカルで編集後、scp でサーバーに転送
scp alexa-api/server.js keinafarm-claude:/home/claude/alexa-api/server.js
scp alexa-api/Dockerfile keinafarm-claude:/home/claude/alexa-api/Dockerfile
scp alexa-api/package.json keinafarm-claude:/home/claude/alexa-api/package.json
scp alexa-api/package-lock.json keinafarm-claude:/home/claude/alexa-api/package-lock.json
# Step 2: サーバーでビルドして再起動
ssh keinafarm-claude
cd /home/claude/alexa-api
sudo docker compose build
sudo docker compose up -d
# Step 3: Traefik 再起動(コンテナ再作成後は必須)
sudo docker restart traefik
```
> **⚠️ 重要**: `docker compose restart` はイメージをリビルドしない。コード変更は `build + up -d` が必要。
### B. Cookie 更新時のデプロイ(ビルド不要)
```bash
# 1. ローカルで GUI を起動
cd /home/akira/develop/windmill_workflow/alexa-api
npm run auth:web
# 2. ブラウザで http://127.0.0.1:3678 を開き、Amazon の認証情報を入力
# 3. 同じ画面の「サーバーへ反映する」を実行
# デフォルト値:
# SSH 接続先: keinafarm
# リモート一時アップロード先: /tmp/alexa-api.env
# 実行する専用コマンド: sudo /usr/local/bin/alexa-cookie-deploy.sh /tmp/alexa-api.env
```
### B-1. Cookie 更新 GUI の初回セットアップ
サーバー側で一度だけ以下を実施する:
```bash
# 1. 専用反映スクリプトを配置
scp alexa-api/alexa-cookie-deploy.sh keinafarm:/tmp/alexa-cookie-deploy.sh
ssh keinafarm 'sudo install -m 755 /tmp/alexa-cookie-deploy.sh /usr/local/bin/alexa-cookie-deploy.sh'
# 2. sudoers を配置
scp alexa-api/alexa-cookie-deploy.sudoers keinafarm:/tmp/alexa-cookie-deploy.sudoers
ssh keinafarm 'sudo install -m 440 /tmp/alexa-cookie-deploy.sudoers /etc/sudoers.d/alexa-cookie-deploy'
# 3. 動作確認
ssh keinafarm 'sudo /usr/local/bin/alexa-cookie-deploy.sh /tmp/not-found.env' || true
```
- `akira` から許可するのは `sudo /usr/local/bin/alexa-cookie-deploy.sh /tmp/alexa-api.env` だけ
- `windmill` への自由な `su` や広い `sudo` 権限は与えない
- 専用スクリプトは `/tmp/alexa-api.env``/home/claude/alexa-api/.env` に反映し、root で `docker compose restart` を実行する
### Traefik 再起動が必要な理由
`docker compose up -d` はコンテナを「再作成」するため、Docker 内部 IP アドレスが変わる。Traefik が古い IP を参照したまま 502/504 エラーを返すため、`sudo docker restart traefik` で新しい IP を再検出させる。
`docker compose restart` はコンテナ再起動のみIP 不変)なので Traefik 再起動は不要。
### docker-compose.yml
```yaml
services:
alexa-api:
build: .
container_name: alexa_api
restart: unless-stopped
env_file:
- .env
environment:
- PORT=3500
networks:
- windmill_windmill-internal
# デバッグ時は以下のコメントを外す:
# ports:
# - "127.0.0.1:3500:3500"
networks:
windmill_windmill-internal:
external: true
```
---
## 9. デバイス一覧
| 名前 | deviceType | serialNumber |
|------|-----------|-------------|
| プレハブ | A4ZXE0RM7LQ7A | G0922H085165007R |
| リビングエコー1 | ASQZWP4GPYUT7 | G8M2DB08522600RL |
| リビングエコー2 | ASQZWP4GPYUT7 | G8M2DB08522503WF |
| オフィスの右エコー | A4ZXE0RM7LQ7A | G0922H08525302K5 |
| オフィスの左エコー | A4ZXE0RM7LQ7A | G0922H08525302J9 |
| 寝室のエコー | ASQZWP4GPYUT7 | G8M2HN08534302XH |
Windmill スクリプトから `device` パラメータに名前またはシリアル番号を渡す。
---
## 10. 運用手順・コマンド集
### サーバー上での確認コマンド
```bash
# コンテナ状態確認
sudo docker ps | grep alexa
# リアルタイムログ確認
sudo docker logs alexa_api -f
# コンテナ停止
sudo docker compose -f /home/claude/alexa-api/docker-compose.yml stop
# ビルド+起動(コード変更後)
cd /home/claude/alexa-api
sudo docker compose build
sudo docker compose up -d
sudo docker restart traefik
# Cookie 更新時(再起動のみ)
sudo docker compose -f /home/claude/alexa-api/docker-compose.yml restart
```
### 動作確認Windmill ワーカーコンテナ内から)
```bash
# ヘルスチェック
curl http://alexa_api:3500/health
# デバイス一覧確認
curl http://alexa_api:3500/devices
# TTS テスト
curl -X POST http://alexa_api:3500/speak \
-H "Content-Type: application/json" \
-d '{"device":"オフィスの右エコー","text":"テストです"}'
```
### Windmill からスクリプト実行
```bash
curl -X POST \
-H "Authorization: Bearer <WindmillトークンWIND>" \
-H "Content-Type: application/json" \
-d '{"device":"オフィスの右エコー","text":"テストです"}' \
"https://windmill.keinafarm.net/api/w/admins/jobs/run_wait_result/p/u/admin/alexa_speak"
```
### API反映後にUI入力欄が変わらない場合2026-03-04 追記)
`u/admin/alexa_speak``create-script` で更新後、API上の `schema` は更新済みでも、Input フォームが旧表示のまま残ることがある。
対応手順:
1. APIで最新状態を確認する
2. `hash` 更新と `schema.properties.device` の以下2項目を確認する
- `format = "dynselect-device"`
- `originalType = "DynSelect_device"`
3. Windmill UI を `Ctrl + Shift + R` でハードリロードする
4. 反映されない場合は `Edit -> Deploy` を1回実行する
5. Input フォームで `Device` がドロップダウン表示になったことを確認する
実運用上は「API反映成功」と「UIフォーム反映成功」を別チェックとして扱う。
---
## 11. 既知の問題・落とし穴
| 問題 | 原因・対処 |
|------|-----------|
| `docker compose restart` してもコードが古い | `restart` はリビルドしない。`build + up -d` を使う |
| コンテナ再作成後に 502/504 エラー | Traefik が古い IP を参照。`sudo docker restart traefik` で解消 |
| alexa-remote2 は使えない | 取得済み Cookie を受け付けず内部再認証で失敗。直接 API 実装が必要 |
| CSRF トークンはヘッダーと Cookie の両方に必要 | 片方だけでは 401 になる |
| `operationPayload``customerId` が必須 | なければ 400 エラー |
| `sequenceJson` の日本語を `\uXXXX` エスケープしないと無音 | Amazon パーサーが raw UTF-8 の日本語をフィルタリングする |
| `Alexa.SpeakSsml` は動作しない | `/api/behaviors/preview` では使えない。`Alexa.Speak` のみ有効 |
| `AlexaAnnouncement` は別用途 | コンテンツでなくノード名が読まれる |
| レート制限 | 短時間連続リクエストで HTTP 429 または無音。通知用途では問題なし |
| Gitea push がブロックされる | pre-receive フックでエラー。ファイル転送は scp を使う |
| 起動ログに Echo デバイスが出ない | deviceType が `A4ZXE` or `ASQZWP` で始まるもののみ表示。新デバイス追加時は確認を |
| test_tts.js のテスト対象が固定 | シリアル `G0922H08525302K5`(オフィスの右エコー)にハードコード。他デバイスでテストする場合は一時的に書き換える |
| auth4.js で CAPTCHA が出る | Amazon のレート制限。しばらく時間を置いてから再実行 |
| `/devices``/speak` のキャッシュが異なる | `/devices` は毎回最新取得、`/speak` は5分キャッシュ。新しいデバイス追加直後に `/speak` が失敗する場合、コンテナ再起動でキャッシュクリア |
---
## 12. ソースファイル索引
### コアコード
| ファイル | 説明 |
|---------|------|
| `alexa-api/server.js` | Express API サーバー。Alexa への直接 HTTPS 実装 |
| `alexa-api/Dockerfile` | node:20-alpine ベース。**`npm install --omit=dev`** で devDependenciesalexa-remote2, alexa-cookie2を除外してビルド。コピーするのは `server.js` のみauth4.js 等はコンテナに含まれない) |
| `alexa-api/docker-compose.yml` | windmill_windmill-internal ネットワーク接続設定 |
| `alexa-api/auth4.js` | Amazon 認証・Cookie 取得(ローカルのみ) |
| `alexa-api/test_tts.js` | ローカルテスト用スクリプト |
| `alexa-api/.env.example` | 環境変数テンプレート |
### ドキュメント
| ファイル | 説明 |
|---------|------|
| `docs/archive/alexa-tts/10_Alexa TTS API 実装記録 (2026-03-02).md` | Claude Code による実装記録(アーカイブ) |
| `docs/archive/alexa-tts/11_色々やってダメだった.txt` | ChatGPT との試行錯誤チャットログ(アーカイブ) |
| `docs/archive/alexa-tts/12_ローカルで試したこと.md` | 日本語TTS問題の調査記録アーカイブ |
| `docs/archive/alexa-tts/21_引き継ぎ_alexa_speak_API反映後にUIドロップダウンが変わらない件.md` | API反映後のUI未反映事象の切り分けと回避策アーカイブ |
| `docs/archive/alexa-tts/README.md` | Alexa TTS 関連の中間資料アーカイブ索引 |
| `docs/30_マスタードキュメント_Alexa_TTS_API編.md` | 本ドキュメント |
---
## 13. 実装の経緯(試行錯誤記録)
### フェーズ1: alexa-remote2 の断念2026-03-02 以前)
当初は `alexa-remote2` ライブラリを使用しようとしたが、取得済みの Cookie 文字列を渡しても内部で再認証を試みて失敗することがわかり、断念。Amazon Alexa API への直接 HTTPS 実装に切り替えた。
### フェーズ2: 英語は動くが日本語が出ない2026-03-02〜03
`Alexa.Speak` で英語は正常に発話されるが、日本語テキストが発話されない問題が発生。試行した内容:
| 試行内容 | 結果 |
|---------|------|
| `speakType: 'ssml'` を operationPayload に追加 | 変化なし(このフィールドは無効) |
| `type: 'Alexa.SpeakSsml'` に変更 | 英語も含め完全無音 |
| `<lang xml:lang="ja-JP">` SSML タグを text に含める | 英語のみ発話(日本語部分は無音) |
| `locale: ''` (空文字) | 英語は読めるが日本語は除去 |
| `locale: 'ja-JP'` | 日本語が除去されるVPSから |
| Cookie 新規取得 | 変化なしCookie は原因ではなかった) |
| `AlexaAnnouncement` ノード | ノード名自体が読まれる(別用途) |
| Unicodeエスケープ `\u3053\u308c...` をテキストに | 変化なし |
### フェーズ3: 根本原因の特定と解決2026-03-03
**決定的な観察**: 日本語と英語が混在したテキスト `'あいうえおThis is Testあいうえお'` を送ると、英語部分(`This is Test`)のみが読まれ、日本語部分(`あいうえお`)は完全に無視された。
**根本原因**: `sequenceJson` パラメータに raw UTF-8 の日本語文字が含まれていると、Amazon のパーサーがそれをフィルタリングして除去する。文字コードの問題ではなく(`\u3053\u308c...` でも同じ結果、JSON の文字列値の中の非 ASCII 文字の扱いの問題だった。
**解決策**: `JSON.stringify()` 後に non-ASCII 文字を `\uXXXX` 形式の JSON エスケープシーケンスに変換する。
```javascript
var rawSequenceJson = JSON.stringify(sequenceObj).replace(
/[\u0080-\uffff]/g,
function(c) { return '\\u' + ('0000' + c.charCodeAt(0).toString(16)).slice(-4); }
);
```
この修正により「これは日本語のテストです」が完璧に発話されることを確認。`server.js``test_tts.js` の両方に適用済み。
---
## 14. 更新履歴
| 日付 | 変更内容 |
|------|---------|
| 2026-03-02 | alexa-remote2 断念、直接 API 実装開始 |
| 2026-03-02〜03 | 日本語TTS問題の調査・試行錯誤 |
| 2026-03-03 | `\uXXXX` エスケープで日本語TTS完全解決。server.js・test_tts.js に反映 |
| 2026-03-03 | 本マスタードキュメント作成 |
| 2026-03-03 | findDevice 検索ロジック、キャッシュ仕様、起動初期化、auth4.js 詳細、Dockerfile 仕様を追記 |
| 2026-03-04 | `u/admin/alexa_speak` の API 反映後にUIドロップダウンが即時反映されない事象と標準対応`Edit -> Deploy`)を統合。中間資料のアーカイブ索引を追加 |

View File

@@ -0,0 +1,211 @@
# Alexa Cookie更新 GUI 運用ドキュメント
> 最終更新: 2026-04-04
> 対象: `/home/akira/develop/windmill_workflow/alexa-api`
> 目的: Alexa Cookie の更新を、ローカル GUI と権限制限付きのサーバー反映で安全に運用する
---
## 1. 何を作ったか
Alexa Cookie の更新作業を、手作業のコマンド列ではなく GUI 中心で進められるようにした。
今回追加・変更したもの:
| ファイル | 役割 |
|---------|------|
| `alexa-api/auth4-core.js` | Amazon 認証と Cookie 取得の共通ロジック |
| `alexa-api/auth4.js` | CLI 版の Cookie 更新ツール。`auth4-core.js` を利用 |
| `alexa-api/auth4-web.js` | ローカルで起動する GUI。Cookie 更新とサーバー反映を行う |
| `alexa-api/alexa-cookie-deploy.sh` | サーバー側の専用反映スクリプト。`/tmp/alexa-api.env` を本番 `.env` に反映し、`docker compose restart` を実行 |
| `alexa-api/alexa-cookie-deploy.sudoers` | `akira` から専用反映スクリプトだけを `sudo` 実行できるようにする sudoers 設定例 |
| `flows/hourly_chime.flow.json` | LINE 通知文面を GUI 手順に合わせて更新 |
この構成にした理由:
- `akira` に広い `sudo` 権限や `windmill` への自由な切り替え権限を与えたくない
- それでも Cookie 更新だけは迷わず再実行できるようにしたい
- そのため「GUI から呼べる専用コマンドだけを sudoers で許可する」構成にした
---
## 2. 全体の流れ
通常運用の流れは以下の通り。
1. ローカルで GUI を起動する
2. ブラウザで Amazon のメールアドレスとパスワードを入力する
3. `alexa-api/.env``ALEXA_COOKIE` を更新する
4. GUI からサーバーへ `/tmp/alexa-api.env` を転送する
5. GUI から `sudo /usr/local/bin/alexa-cookie-deploy.sh /tmp/alexa-api.env` を実行する
6. サーバー側で `.env` 反映と `alexa_api` コンテナ再起動が行われる
---
## 3. 初回セットアップ
このセットアップはサーバーで一度だけ必要。
### 3-1. 専用スクリプトを配置
```bash
scp /home/akira/develop/windmill_workflow/alexa-api/alexa-cookie-deploy.sh keinafarm:/tmp/alexa-cookie-deploy.sh
ssh keinafarm 'sudo install -m 755 /tmp/alexa-cookie-deploy.sh /usr/local/bin/alexa-cookie-deploy.sh'
```
### 3-2. sudoers を配置
```bash
scp /home/akira/develop/windmill_workflow/alexa-api/alexa-cookie-deploy.sudoers keinafarm:/tmp/alexa-cookie-deploy.sudoers
ssh keinafarm 'sudo install -m 440 /tmp/alexa-cookie-deploy.sudoers /etc/sudoers.d/alexa-cookie-deploy'
```
### 3-3. 何が許可されるか
`alexa-cookie-deploy.sudoers` の内容:
```sudoers
akira ALL=(root) NOPASSWD: /usr/local/bin/alexa-cookie-deploy.sh /tmp/alexa-api.env
```
許可しているのはこれだけ:
- `akira` が root として `/usr/local/bin/alexa-cookie-deploy.sh /tmp/alexa-api.env` を実行すること
許可していないもの:
- `windmill` への自由な `su`
- 任意コマンドの `sudo`
- 任意パスへのコピーや任意の Docker 操作
---
## 4. ふだんの使い方
### 4-1. GUI を起動
```bash
cd /home/akira/develop/windmill_workflow/alexa-api
npm run auth:web
```
ブラウザで次を開く:
```text
http://127.0.0.1:3678
```
### 4-2. Cookie を更新
画面上部の「Alexa Cookie 更新」で次を入力する。
- Amazon メールアドレス
- Amazon パスワード
「Cookie を更新する」を押すと、成功時は `alexa-api/.env` が更新される。
### 4-3. サーバーへ反映
画面下部の「サーバー反映」で次を確認する。
- SSH 接続先: `keinafarm`
- リモート一時アップロード先: `/tmp/alexa-api.env`
- 実行する専用コマンド: `sudo /usr/local/bin/alexa-cookie-deploy.sh /tmp/alexa-api.env`
「サーバーへ反映する」を押すと、内部では次を実行する。
```bash
scp /home/akira/develop/windmill_workflow/alexa-api/.env keinafarm:/tmp/alexa-api.env
ssh keinafarm 'sudo /usr/local/bin/alexa-cookie-deploy.sh /tmp/alexa-api.env'
```
成功時はログに以下のように表示される。
```text
alexa cookie deployed
Container alexa_api Restarting
Container alexa_api Started
反映完了
```
---
## 5. 各ファイルの使い分け
### `auth4-web.js`
通常はこちらを使う。人が毎回コマンドを思い出さなくてよい。
### `auth4.js`
CLI で直接更新したい時のために残してある。基本運用は GUI を推奨。
例:
```bash
cd /home/akira/develop/windmill_workflow/alexa-api
AMAZON_EMAIL="xxx" AMAZON_PASSWORD="xxx" node auth4.js
```
### `auth4-core.js`
直接実行するものではない。CLI 版と GUI 版の共通認証ロジック。
### `alexa-cookie-deploy.sh`
サーバー側で root として動く専用スクリプト。役割は次の3つ。
1. `/tmp/alexa-api.env``ALEXA_COOKIE=` があるか確認
2. `/home/claude/alexa-api/.env` を更新
3. `docker compose --env-file /home/claude/alexa-api/.env -f /home/claude/alexa-api/docker-compose.yml restart` を実行
---
## 6. LINE 通知との関係
`flows/hourly_chime.flow.json` の失敗時通知は、この GUI 運用に合わせて更新済み。
通知では次の導線を案内する。
1. `npm run auth:web`
2. `http://127.0.0.1:3678` を開く
3. GUI 上でサーバー反映まで実行する
これにより「あとでどう直すのか分からない」状態になりにくくしている。
---
## 7. トラブルシュート
### `Could not resolve hostname ...`
SSH 接続先の別名が `~/.ssh/config` に存在しない。
今回の環境では使える別名は `keinafarm`
### `Permission denied` で `/home/claude/alexa-api/.env` に書けない
`scp` で直接 `/home/claude/alexa-api/.env` へ置こうとすると起きる。
現在は `/tmp/alexa-api.env` に送ってから、専用スクリプトで反映する設計に変更済み。
### `stat /home/claude/alexa-api/.env: permission denied`
旧版の専用スクリプトで、権限不足のユーザーに切り替えて `docker compose` を実行していた時の症状。
現在の `alexa-cookie-deploy.sh` は root のまま `.env` 反映と再起動を完了する。
### CAPTCHA や MFA が出る
Amazon 側で追加認証が必要。GUI のログに失敗理由が出る。
この場合は一発で通らない可能性がある。
---
## 8. 関連ファイル
- `docs/30_マスタードキュメント_Alexa_TTS_API編.md`
- `alexa-api/auth4-web.js`
- `alexa-api/auth4.js`
- `alexa-api/auth4-core.js`
- `alexa-api/alexa-cookie-deploy.sh`
- `alexa-api/alexa-cookie-deploy.sudoers`
- `flows/hourly_chime.flow.json`

View File

@@ -0,0 +1,148 @@
Alexa TTS API マスタードキュメント
最終更新: 2026-03-03 状態: サーバーからの日本語TTS未解決調査中
------------
2026/03/03 10:24 akira記録
akiraが下記の変更をしましたので、内容を読んでください。
1) 構成とサーバーへのファイル受け渡し方法を変更しました
/home/claude/windmill_workflow
に、https://gitea.keinafarm.net/akira/windmill_workflow.gitをcloneしました
これにより、
C:\Users\akira\Develop\windmill_workflow
とのやり取りはgiteaを使って出来るようになります。
2) docker compose up -dで、「 Docker 内部の IP アドレスが変わり、Traefik のルーティングが古い IP を参照したまま 502/504 エラーになる」のは、原因があるはずです。(他のコンテナで、問題になっていないので)
調査して、Traefik 再起動が不必要になるようにしたいです
------------
概要
Windmill から家中の Echo デバイスに任意のテキストを読み上げさせる API サーバー。Node.js + Express で実装し、Docker コンテナとして Windmill サーバー上で稼働する。
⚠ 現在の問題: ローカル PC からは日本語TTS動作確認済み。しかしサーバーkeinafarm.netのコンテナからリクエストすると日本語文字が Amazon 側で除去されて発話されない。原因は Amazon の IP ベースフィルタリング海外IPからは日本語 textToSpeak を無視する模様)。調査継続中。
ファイル構成
ファイル 場所 役割 備考
server.js alexa-api/(リポジトリ) Express API サーバー本体 本番コード。変更したらビルド・再デプロイが必要
Dockerfile alexa-api/(リポジトリ) Docker イメージ定義 node:20-alpine ベース。server.js と package*.json をコピー
docker-compose.yml alexa-api/(リポジトリ) コンテナ起動設定 windmill_windmill-internal ネットワーク接続。外部ポート非公開
package.json / package-lock.json alexa-api/(リポジトリ) npm 依存関係 本番: express のみ。devDeps に alexa-remote2不使用
.env.example alexa-api/(リポジトリ) 環境変数テンプレート ALEXA_COOKIE=xxx の形式
.env alexa-api/(リポジトリ、.gitignore 対象) 実際の Cookie 保管 Git にコミットしない。ローカル作業後に scp でサーバーへ転送
auth4.js alexa-api/(リポジトリ) Amazon 認証・Cookie 取得スクリプト ローカルのみで実行Windows PC
auth.js / auth2.js / auth3.js alexa-api/(リポジトリ) auth4.js の旧バージョン 参考用。実際は auth4.js を使う
test_tts.js alexa-api/(リポジトリ) ローカルテスト用スクリプト 直接 alexa.amazon.co.jp を叩いて動作確認
サーバー上のファイル場所: /home/claude/alexa-api/git リポジトリとは別にコピーして管理)
サーバーへのデプロイ手順
server.js や Dockerfile、package.json を変更した場合は以下の手順でサーバーに反映する。
Step 1: ローカルでファイルを編集
リポジトリc:\Users\akira\Develop\windmill_workflow\alexa-api\)でファイルを編集する。
Step 2: scp でサーバーに転送
変更したファイルをサーバーに scp で転送する:
# server.js を変更した場合 scp alexa-api/server.js keinafarm-claude:/home/claude/alexa-api/server.js # Dockerfile や package.json を変更した場合 scp alexa-api/Dockerfile keinafarm-claude:/home/claude/alexa-api/Dockerfile scp alexa-api/package.json keinafarm-claude:/home/claude/alexa-api/package.json scp alexa-api/package-lock.json keinafarm-claude:/home/claude/alexa-api/package-lock.json # .env を更新した場合Cookie 更新時など) scp alexa-api/.env keinafarm-claude:/home/claude/alexa-api/.env
Step 3: サーバーでビルドして再起動
⚠ 重要: docker compose restart はイメージをリビルドしない。server.js 等を変更した場合は必ず build + up -d を実行すること。
# SSH でサーバーに接続してビルド+起動 ssh keinafarm-claude cd /home/claude/alexa-api # イメージをビルドserver.js 等の変更を反映) sudo docker compose build # コンテナを再作成して起動 sudo docker compose up -d # Traefik を再起動(コンテナ再作成後は必須) sudo docker restart traefik
Step 4: 動作確認
# ヘルスチェックWindmill ワーカーコンテナ内から) curl http://alexa_api:3500/health # ログ確認 sudo docker logs alexa_api -f # デバイス一覧確認 curl http://alexa_api:3500/devices
Cookie だけ更新する場合server.js 変更なし)
# .env をサーバーに転送 scp alexa-api/.env keinafarm-claude:/home/claude/alexa-api/.env # コンテナを再起動restart で OK → イメージのリビルド不要) ssh keinafarm-claude 'sudo docker compose -f /home/claude/alexa-api/docker-compose.yml restart' # ※ Traefik の再起動は不要(コンテナ再作成しないため)
Traefik 再起動が必要な理由
docker compose up -d はコンテナを「再作成」するdocker compose restart は既存コンテナを再起動するだけ)。コンテナが再作成されると Docker 内部の IP アドレスが変わり、Traefik のルーティングが古い IP を参照したまま 502/504 エラーになる。
対処: sudo docker restart traefik で Traefik に新しい IP を再検出させる。
この問題は Traefik の設定で watch: true にすれば自動解消できるが、現状はコンテナ再作成のたびに手動で Traefik を再起動する運用としている。
docker-compose.yml の内容
services: alexa-api: build: . container_name: alexa_api restart: unless-stopped env_file: - .env environment: - PORT=3500 networks: - windmill_windmill-internal # 外部には公開しないWindmill ワーカーから内部ネットワーク経由でのみアクセス) # デバッグ時は以下のコメントを外す: # ports: # - "127.0.0.1:3500:3500" networks: windmill_windmill-internal: external: true
認証方法auth4.js
Amazon Japan OpenID フローを自前で実装。ローカル PCWindowsでのみ実行する
# ローカルPC の alexa-api ディレクトリで実行 cd alexa-api AMAZON_EMAIL="メールアドレス" AMAZON_PASSWORD="パスワード" node auth4.js
成功すると alexa-api/.env が生成または更新される。
ログインフローの概要:
GET https://www.amazon.co.jp/ap/signin?openid.assoc_handle=amzn_dp_project_dee_jp
hidden フィールドanti-csrftoken-a2z, appActionToken, workflowState 等)を抽出
POST でメール/パスワードを送信
alexa.amazon.co.jp/api/apps/v1/token へのリダイレクトをたどる
取得した Cookieat-acbjp, sess-at-acbjp, sst-acbjp, session-token 等)を .env に保存
TTS の仕組みserver.js
alexa-remote2 は使わない直接 API 実装。Endpoints:
POST /speak — { device: "デバイス名 or serial", text: "しゃべる内容" }
GET /devices — デバイス一覧
GET /health — ヘルスチェック
内部の API 呼び出し順序:
GET /api/language → Set-Cookie: csrf=XXXXX を取得(毎リクエストごと)
GET /api/bootstrap → customerId を取得(キャッシュ: 永続A1AE8HXD8IJ61L
GET /api/devices-v2/device → デバイス一覧5分キャッシュ
POST /api/behaviors/preview にシーケンス JSON を送信
POST /api/behaviors/preview のボディ構造:
{ behaviorId: "PREVIEW", sequenceJson: JSON.stringify({ "@type": "com.amazon.alexa.behaviors.model.Sequence", startNode: { "@type": "com.amazon.alexa.behaviors.model.OpaquePayloadOperationNode", type: "Alexa.Speak", operationPayload: { deviceType: "...", deviceSerialNumber: "...", customerId: "A1AE8HXD8IJ61L", locale: "ja-JP", // ← 重要(下記参照) textToSpeak: "発話内容" } } }), status: "ENABLED" }
ヘッダーに csrf: XXXXX と Cookie に csrf=XXXXX の両方が必要。Content-Length は Buffer.byteLength で計算(マルチバイト文字対応)。
⚠ locale パラメータについて(重要・未解決)
locale 値 ローカル PC から サーバーkeinafarm.netから
""(空文字) ✅ 日本語・英語・漢字全て発話 ❌ 英語TTSになり日本語部分が発話されない
"ja-JP" ❌ 一瞬音が出るだけ(失敗) ❌ 日本語文字が Amazon 側で除去され英字のみ発話
現在 server.js では locale: "ja-JP" に設定している。
仮説: Amazon が海外IPkeinafarm.net = 非日本IPからのリクエストを IP ベースでフィルタリングし、textToSpeak の日本語文字を除去している。Alexa.TextCommand は同じ問題がない(異なる API パス)。
確認済み事実: alexa_api の server.js ログには日本語テキストが正しく届いている。除去は Amazon サーバー側で発生。
次の調査候補:
SSML の <lang xml:lang="ja-JP"> タグで強制的に日本語 TTS を指定できるか
Alexa.TextCommand で「次を読み上げて:{text}」形式が使えるか
ローカルブリッジ方式(ユーザーのローカル PC で小さなプロキシサーバーを動かし、クラウドサーバーからローカル経由で alexa.amazon.co.jp を叩く)
デバイス一覧Echo デバイスのみ)
名前 deviceType serialNumber
プレハブ A4ZXE0RM7LQ7A G0922H085165007R
リビングエコー1 ASQZWP4GPYUT7 G8M2DB08522600RL
リビングエコー2 ASQZWP4GPYUT7 G8M2DB08522503WF
オフィスの右エコー A4ZXE0RM7LQ7A G0922H08525302K5
オフィスの左エコー A4ZXE0RM7LQ7A G0922H08525302J9
寝室のエコー ASQZWP4GPYUT7 G8M2HN08534302XH
Windmill スクリプトu/admin/alexa_speak
export async function main(device: string, text: string) { const res = await fetch("http://alexa_api:3500/speak", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ device, text }), }); if (!res.ok) throw new Error("alexa-api error " + res.status); return res.json(); }
device はデバイス名日本語またはシリアル番号で指定可能。Windmill ワーカーから http://alexa_api:3500 でアクセスwindmill_windmill-internal ネットワーク経由)。
Cookie の更新手順
Cookie は数日〜数週間で期限切れ。切れたら:
# 1. ローカル PC で Cookie を取得 cd alexa-api AMAZON_EMAIL="メールアドレス" AMAZON_PASSWORD="パスワード" node auth4.js # → alexa-api/.env が更新される # 2. サーバーに .env を転送 scp alexa-api/.env keinafarm-claude:/home/claude/alexa-api/.env # 3. コンテナを再起動restart で OK、リビルド不要 ssh keinafarm-claude 'sudo docker compose -f /home/claude/alexa-api/docker-compose.yml restart' # ※ Traefik 再起動は不要(コンテナ再作成なし)
既知の問題・落とし穴
docker compose restart ≠ リビルド: server.js を変更しても restart ではコンテナ内のコードは古いまま。build + up -d が必要。
コンテナ再作成後は Traefik 再起動必須: up -d でコンテナ再作成すると Docker 内部 IP が変わり Traefik が 502/504 を返す。sudo docker restart traefik で解消。
alexa-remote2 は使えない: 取得した Cookie 文字列を受け付けない(内部で再認証しようとして失敗)。直接 API 実装が必要。
CSRF トークンは Cookie と ヘッダーの両方に必要: csrf ヘッダーだけ、または Cookie だけでは認証失敗。
operationPayload に customerId 必須: ないと 400 エラー。
レート制限: 短時間に連続リクエストすると HTTP 429 または 200 で音が出ない。通常の通知用途では問題なし。
git push がブロックされる: Gitea の pre-receive フックremote: Gitea: User permission denied for writingで push が失敗する。根本原因は未調査。ファイル転送は scp で行っている。
firstRunCompleted: false はデバイス設定の未完了フラグ: TTS には直接影響しないroot cause ではなかった)。
サーバー上の運用コマンド一覧
# コンテナ状態確認 sudo docker ps | grep alexa # リアルタイムログ確認 sudo docker logs alexa_api -f # コンテナ停止 sudo docker compose -f /home/claude/alexa-api/docker-compose.yml stop # ビルド+起動(コード変更後) cd /home/claude/alexa-api sudo docker compose build sudo docker compose up -d sudo docker restart traefik # Cookie 更新時(再起動のみ) sudo docker compose -f /home/claude/alexa-api/docker-compose.yml restart

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,163 @@
# Alexa 日本語 TTS 問題 試行記録
最終更新: 2026-03-03
担当: akira + AI (Antigravity)
---
## 現在の問題
`/api/behaviors/preview` + `Alexa.Speak` を使って日本語テキストを TTSで発話させようとしているが、
**日本語Unicode文字だけが Amazon 側でフィルタリングされ、発話されない。**
ASCII文字英語は正常に発話される。
---
## 環境
- テスト用スクリプト: `alexa-api/test_tts.js`ローカルPCから直接 alexa.amazon.co.jp を叩く)
- 本番: `alexa-api/server.js`VPS上のDockerコンテナ
- テストデバイス: オフィスの右エコー (serial: G0922H08525302K5, type: A4ZXE0RM7LQ7A)
- Alexaアプリでデバイス言語設定: **日本語** に設定済み(確認済み)
- VPS IP: 162.43.33.56大阪・Xserver Inc. = 日本国内 ✅
---
## 試行ログ(時系列)
### 【サーバー側での試行】ChatGPT との会話ログより、2026-03-02〜03
#### ❌ `speakType: 'ssml'` を `operationPayload` に追加
```json
"type": "Alexa.Speak",
"operationPayload": { ..., "speakType": "ssml" }
```
→ 変化なし。`Alexa.Speak` はSSML非対応のため無効。
#### ❌ `type: 'Alexa.SpeakSsml'` に変更 + `textToSpeak` にSSMLなし
```json
"type": "Alexa.SpeakSsml",
"operationPayload": { ..., "textToSpeak": text }
```
→ 英語も含めて完全無音LEDも反応なし
#### ❌ `Alexa.SpeakSsml` + `textToSpeak: '<speak>'+text+'</speak>'`
→ 英語も無音。`Alexa.SpeakSsml``textToSpeak` ではなく別キーを要求する模様。
#### ❌ `Alexa.SpeakSsml` + `ssml: ssml`(キー名を変更)
→ 英語も発話せず。
**ChatGPTの最終見解:** `/api/behaviors/preview` では `Alexa.SpeakSsml` は動作しないAPIの癖`Alexa.Speak` に戻すしかない。
---
### 【ローカルPCでの試行】2026-03-03 午前)
#### ❌ `locale: 'ja-JP'` + 日本語テキストtest_tts.js デフォルト)
```js
locale: 'ja-JP',
textToSpeak: 'テストです。聞こえますか'
```
→ 「エ」だけ発話(最初の「テ」の母音のみ)。
#### ✅ `locale: ''` + ASCII: `'hello'`
→ 「ハロー」と正常発話。英語は問題なし。
#### ❌ `locale: ''` + 日本語: `'テストです。聞こえますか'`
→ 「エ」のみ。デバイス言語が英語設定ならこの動作になるが、日本語設定確認済みのため別原因。
#### ❌ `locale: 'ja-JP'` + 日本語: `'テストです。これは日本語のテストです'`
→ 「えんえ」のような音のみ(断片的な音)。
#### ❌ `locale: 'ja-JP'` + ひらがな: `'あいうえお'`
→ 無音LEDは点滅 = 通知は届いている)。
#### 🔍 `locale: 'ja-JP'` + 混在: `'あいうえおThis is Testあいうえお'`
→ 「ディスイズテスタ」のみ発話。
**重要: 日本語部分は無音、ASCII部分のみ日本語アクセントで読まれる。**
→ Amazon側で日本語Unicodeを除去している証拠。
#### ❌ `locale: 'ja-JP'` + Unicodeエスケープ: `'\u3053\u308c\u306f\u30c6\u30b9\u30c8\u3067\u3059'`
→ 無音。ファイルエンコード問題ではないUnicodeエスケープ = `これはテストです` と同一)。
**→ 文字コードの問題ではないことが確定。**
#### ❌ `type: 'AlexaAnnouncement'` + locale:`'ja-JP'` + content[].speak構造
```json
"type": "AlexaAnnouncement",
"operationPayload": {
"content": [{ "locale": "ja-JP", "speak": { "type": "text", "value": "日本語のテストです" } }],
"target": { "devices": [...] }
}
```
→ 「えんえせんと」("AlexaAnnouncement" を日本語発音で読んだもの)。
コンテンツではなくノード型名が読まれた → このノードタイプは別用途。
---
## 確定した事実
| 事実 | 根拠 |
|------|------|
| 通知自体は届いている | LEDが点滅する |
| 英語ASCIIは正常発話 | "hello" → 「ハロー」、"This is Test" → 「ディスイズテスタ」 |
| 日本語Unicodeのみ除去される | 混在テキストで確認。Unicodeエスケープでも同じ |
| デバイス言語設定は日本語 | Alexaアプリで確認済み |
| サーバーIPは日本大阪 | ipinfo.io で確認: Xserver Inc., JP |
| 文字コードは問題なし | Unicodeエスケープテストで確定 |
| `Alexa.SpeakSsml` 系は全て失敗 | 英語含め無音 |
| `AlexaAnnouncement` は別用途 | ノード型名が読まれた |
---
## 仮説(現在)
Amazon の `/api/behaviors/preview` エンドポイントが、
何らかの理由で `textToSpeak` 内の日本語Unicodeを除去している。
考えられる原因:
1. **セッション/Cookie が古くなりJapanese TTS権限が変わった**Cookie の再生成で解消する可能性)
2. **Amazonが API の挙動を変更した**非公開APIのためいつでも変更しうる
3. **別のAPIエンドポイントが必要**(未探索のルートがある可能性)
---
## 試行ログ続き2026-03-03 午後)
#### Cookie 新規取得auth4.js 再実行)
→ 変化なし。Cookie は原因ではなかった。
#### ❌ `AlexaAnnouncement` ノードタイプ
→ 「えんえせんと」("AlexaAnnouncement" を日本語で読んだ)。コンテンツではなくノード名が読まれた。別用途のノード。
#### ✅ **解決!** `sequenceJson` の non-ASCII を `\uXXXX` エスケープに変換
```javascript
var rawSequenceJson = JSON.stringify(sequenceObj).replace(
/[\u0080-\uffff]/g,
function(c) { return '\\u' + ('0000' + c.charCodeAt(0).toString(16)).slice(-4); }
);
```
→ 「これは日本語のテストです」が完璧に発話された!
---
## ✅ 解決済み2026-03-03
**根本原因**: `sequenceJson` 内の日本語文字を raw UTF-8 のまま Amazon に送ると、Amazon 側のパーサーがそれをフィルタリングして無視する。
**解決策**: `JSON.stringify(sequenceObj)` 後に non-ASCII 文字(`\u0080` 以上)を `\uXXXX` 形式のJSONエスケープシーケンスに変換してから `sequenceJson` として送る。
**修正箇所**: `alexa-api/server.js``alexa-api/test_tts.js`
**確定したパラメータ**:
- `type: 'Alexa.Speak'`
- `locale: 'ja-JP'`
- `textToSpeak: <日本語テキスト>`
- `sequenceJson` は non-ASCII を `\uXXXX` エスケープして送る
---
## 参考
- 実装記録: `docs/alexa-api/10_Alexa TTS API 実装記録 (2026-03-02).md`

View File

@@ -0,0 +1,105 @@
# 引き継ぎ - `u/admin/alexa_speak` API反映後にUIドロップダウンが変わらない件
> **作成日**: 2026-03-04
> **対象**: `windmill.keinafarm.net` / workspace `admins`
> **対象スクリプト**: `u/admin/alexa_speak`
> **目的**: 別端末から同じ事象に遭遇しても、原因切り分けと復旧をすぐ実施できるようにする
---
## 1. 事象の概要
`u/admin/alexa_speak` を Windmill API`create-script`)で更新した直後、
- Scriptタブ上のコードは更新済み
- `schema` 上も `device``dynselect-device`
- しかし Inputフォームは `Device` がテキスト入力のまま(ドロップダウンにならない)
という状態になった。
---
## 2. 当日の時系列(要点)
1. 既存スクリプトを取得し、ローカル `scripts/alexa_speak.ts`Dynamic Select実装ありをAPIで反映
2. サーバー再取得で `content` 一致を確認(更新自体は成功)
3. UIを開くと `Device` が入力欄のままで、ドロップダウン化されていない
4. `schema.device``format: dynselect-device`, `originalType: DynSelect_device` に更新して再反映
5. それでも UI は直ちには変わらず
6. Windmill UIで `Edit` に入り、`Deploy` を1回実施
7. 直後にドロップダウン表示へ反映
---
## 3. 確認できた事実
- API反映は成功しているhash更新
- 中間: `a6010687183a199d`
- 最終: `318d78f45a084e32`
- 最終状態では以下がAPIで確認済み
- `schema.properties.device.format = "dynselect-device"`
- `schema.properties.device.originalType = "DynSelect_device"`
- UI反映は API反映だけでは即時にならず、`Edit -> Deploy` 後に反映された
---
## 4. 想定される原因
Windmill CE 側で、API経由更新時にフォームUIメタ情報入力ウィジェット解決の再計算または再適用が即時反映されないケースがある。
実務上は「API更新後にUIで1回Deploy」が回避策として有効。
---
## 5. 再現時の標準対応手順Runbook
### 5.1 APIでスクリプト更新
```bash
cd /home/akira/develop/windmill_workflow
./wm-api.sh get-script u/admin/alexa_speak > /tmp/remote_alexa_speak.json
# parent_hash を含む payload を作成して create-script
./wm-api.sh create-script /tmp/alexa_speak_push.json
```
### 5.2 APIで反映確認
```bash
./wm-api.sh get-script u/admin/alexa_speak
```
確認ポイント:
- `hash` が更新されている
- `content` が想定コードになっている
- `schema.properties.device.format``dynselect-device`
- `schema.properties.device.originalType``DynSelect_device`
### 5.3 UI反映されない場合
1. `u/admin/alexa_speak` を最新リビジョンで開く
2. ハードリロード(`Ctrl + Shift + R`
3. 変化がなければ `Edit -> Deploy` を1回実施
4. Inputフォームの `Device` がドロップダウン化されたことを確認
---
## 6. 補足(今回の最終状態)
- スクリプト: `u/admin/alexa_speak`
- 期待UI:
- `Device`: ドロップダウンdynselect
- `Text`: テキスト入力
- 前提:
- `alexa_api` コンテナが稼働
- `http://alexa_api:3500/devices` が取得可能
---
## 7. 引き継ぎメモ
- 「API反映成功」と「UIフォーム反映成功」は同時とは限らない
- 引き継ぎ時は、必ず以下をセットで確認する
1. APIレスポンスの `hash``schema`
2. UI表示必要なら `Edit -> Deploy`

View File

@@ -0,0 +1,24 @@
# Alexa TTS API 関連アーカイブ
最終統合ドキュメントは以下:
- `docs/30_マスタードキュメント_Alexa_TTS_API編.md`
途中経過・検証メモ(本アーカイブ内コピー):
- `docs/archive/alexa-tts/10_Alexa TTS API 実装記録 (2026-03-02).md`
- `docs/archive/alexa-tts/11_色々やってダメだった.txt`
- `docs/archive/alexa-tts/12_ローカルで試したこと.md`
- `docs/archive/alexa-tts/21_引き継ぎ_alexa_speak_API反映後にUIドロップダウンが変わらない件.md`
原本の参照先:
- `docs/alexa-api/10_Alexa TTS API 実装記録 (2026-03-02).md`
- `docs/alexa-api/11_色々やってダメだった.txt`
- `docs/alexa-api/12_ローカルで試したこと.md`
- `docs/flow-manage/11_引き継ぎ_alexa_speak_API反映後にUIドロップダウンが変わらない件.md`
補足:
- `docs/flow-manage/10_マスタードキュメント_Windmillフロー管理_API一本化編.md`
Alexa TTS 単体ではなく、Windmill API運用全体の親ドキュメントとして維持する。

View File

@@ -0,0 +1,362 @@
# マスタードキュメント - Windmillフロー管理 API一本化編
> **最終更新**: 2026-03-03
> **対象**: `windmill.keinafarm.net` / workspace `admins`
> **目的**: ローカルGitとサーバーGitの衝突を避けつつ、Windmill APIを唯一の運用経路に統一する
---
## 目次
1. [この文書の役割](#1-この文書の役割)
2. [運用方針(結論)](#2-運用方針結論)
3. [現状の課題と解決方針](#3-現状の課題と解決方針)
4. [管理対象と正本の定義](#4-管理対象と正本の定義)
5. [同期・反映の仕様](#5-同期反映の仕様)
6. [競合時の動作仕様](#6-競合時の動作仕様)
7. [実装計画](#7-実装計画)
8. [標準運用手順Runbook](#8-標準運用手順runbook)
9. [セキュリティ・監査方針](#9-セキュリティ監査方針)
10. [障害前提の復旧設計(必須)](#10-障害前提の復旧設計必須)
11. [Windmill依存を薄くする方針必須](#11-windmill依存を薄くする方針必須)
12. [受け入れ条件](#12-受け入れ条件)
13. [既知の注意点](#13-既知の注意点)
14. [更新履歴](#14-更新履歴)
---
## 1. この文書の役割
この文書は、次回セッション開始時にこれだけ読めば作業を継続できることを目的とした、**運用仕様 + 実装計画の単一ソース**である。
- 暗黙知を残さない
- 方針・手順・失敗時の扱いを固定化する
- API経由運用に必要な実装タスクを明文化する
---
## 2. 運用方針(結論)
### 採用する方式: API一本化Server First
1. ローカルリポジトリは、サーバー側Gitをリモートにしない
2. Windmillの実体変更は **Windmill REST API 経由のみ**
3. 作業開始時にAPIでサーバー状態を取り込み、サーバーが新しければローカルへ同期
4. **運用単位は「workflow packageflow + schedules」を基本**とする
5. ローカル変更はAPIでサーバーへ反映
6. サーバー側の Git Sync Workflow は定期記録用途として継続
7. ローカルGitコミットは手動またはAIで実施し、監査/履歴用途として扱う
---
## 3. 現状の課題と解決方針
### 課題
- Windmill側の自動Git記録WFと、ローカルGit運用が同一系統を触ると競合が発生する
- `wmill` CLIは制約があり使いにくく、運用が不安定
- サーバーとローカルのどちらが最新か判定が曖昧
### 解決
- 更新経路をWindmill APIに統一し、競合面を縮小する
- 「正本=Windmillサーバー」の原則を固定する
- `hash` / `parent_hash` を使った衝突検知を標準化する
---
## 4. 管理対象と正本の定義
### 正本
- **Windmillサーバー上のオブジェクト**
- scripts (`/w/{workspace}/scripts/*`)
- flows (`/w/{workspace}/flows/*`)
- schedules (`/w/{workspace}/schedules/*`)
### ローカルの位置づけ
- ローカルファイルは「編集用ワークツリー + 監査ログ」
- ローカルGitはサーバー同期の必須経路ではない
### 管理単位(重要)
- 単体オブジェクト運用scriptだけ、flowだけは補助用途
- 標準運用は **workflow package 単位** とする
- `flow` 本体
- その `flow path` に紐づく `schedules``is_flow=true` かつ `script_path == flow.path`
### 既存の主要オブジェクト(例)
- script: `u/admin/alexa_speak`
- flows: `f/app_custom/system_heartbeat`, `f/shiraou/shiraou_notification` など
---
## 5. 同期・反映の仕様
## 5.1 Pullサーバー -> ローカル)
### 目的
- 作業前にサーバー最新状態をローカルへ取り込む
### 判定ルール
- サーバー `updated_at` / `hash` とローカル管理メタ情報を比較
- サーバーが新しければローカルを更新
### 対象APIオブジェクト単体
- `GET /api/w/{workspace}/scripts/get/p/{path}`
- `GET /api/w/{workspace}/flows/get/{path}`
- `GET /api/w/{workspace}/schedules/get/{path}`
### workflow package Pull標準
1. `GET /flows/get/{flow_path}` で flow 本体取得
2. `GET /schedules/list` で schedule 一覧取得
3. `script_path == flow_path` の schedule 群を抽出
4. ローカルへ一括保存flow + schedules
## 5.2 Pushローカル -> サーバー)
### 目的
- ローカル変更をサーバーへ安全反映
### スクリプト反映API標準
- `POST /api/w/{workspace}/scripts/create`
必須パラメータ(最小):
- `path`
- `parent_hash`直前取得したサーバーhash
- `summary`
- `description`
- `content`
- `schema`
- `language`TypeScriptは `bun`
- `kind`(通常 `script`
- `lock`(既存値継承)
### フロー反映API
- CE制約により `PUT` 更新が不可/不安定な場合があるため、原則:
1. `DELETE /flows/delete/{path}`
2. `POST /flows/create`
### schedule反映APIworkflow package の一部)
- workflow package Push時は、対象 flow に紐づく schedule も同時同期する
- 原則:
1. サーバー現行 schedule`script_path == flow_path`)一覧取得
2. ローカル定義との差分計算(追加・更新・削除)
3. `DELETE /schedules/delete/{path}``POST /schedules/create` で収束
---
## 6. 競合時の動作仕様
### スクリプトPush競合
- `parent_hash` 不一致時は更新拒否(期待動作)
- 対応:
1. 最新をPull
2. 差分を再適用
3. 再Push
### フローPush競合
- 削除再作成前に最新を必ずPullしてローカル保存
- 競合が疑われる場合は自動実行せず手動確認にフォールバック
- **Preflight必須**: Push直前に `remote_index` の既知hashとサーバー現在hashを比較し、不一致ならPush中断fail closed
- Push後は `post-verify`再取得して期待JSON一致確認を必須化
### schedulePush競合
- `schedule.path` 重複や同名上書きに注意
- workflow package Push時は、対象 flow の schedule 群をまとめて同期し、中途半端な状態を残さない
- 失敗時は flow と schedules の両方を再取得して整合を確認
- schedule 同期も Preflight/`post-verify` の対象に含める
---
## 7. 実装計画
## Phase 0: 文書固定(完了)
- 本ドキュメント作成
## Phase 1: API運用コマンド整備完了
`wm-api.sh` へ追加:
1. `pull-script <path> <outfile>`
2. `push-script <path> <infile>`
3. `pull-flow <path> <outfile>`
4. `push-flow <json-file>`
5. `pull-all`scripts/flowsの一覧取得 + 一括保存)
6. `status-remote`ローカルとサーバーのhash比較
## Phase 2: workflow package 対応(次タスク)
`wm-api.sh` へ追加:
1. `pull-workflow <flow_path>`flow + schedules 一括取得)
2. `push-workflow <workflow-dir>`flow反映 + schedules差分同期
3. `status-workflow [flow_path]`workflow単位の差分表示
4. `pull-all-workflows`flow全件を workflow package で取得)
## Phase 3: メタ情報管理
- `state/remote_index.json` を拡張し、`scripts/flows/schedules` を保持
- `state/workflow_index.json` を導入し、`workflow -> flow_hash + schedule_hashes` を保持
## Phase 4: 標準化
- `docs/flow-manage` に操作例を固定
- 「作業開始時は必ずpull」を運用ルール化
## Phase 5: 半自動化(任意)
- AI/スクリプトで
- 変更検知
- Pull提案
- Push時の競合自動リカバリ
---
## 8. 標準運用手順Runbook
### 8.1 日常の更新(推奨)
1. `pull-all` でサーバー最新を取得
2. ローカル編集
3. workflow修正時は `push-workflow` で反映flow + schedules
4. script単体修正時のみ `push-script` を使う
5. 必要に応じてローカルGitにコミット
### 8.2 サーバーで変更されたものを取り込む
1. `status-remote` 実行
2. workflow単位の変更は `status-workflow` / `pull-workflow` で取得
3. 単体変更は `pull-*` で取得
4. ローカル履歴としてコミット(任意)
### 8.3 `alexa_speak` 更新例(現在の具体例)
1. `pull-script u/admin/alexa_speak scripts/alexa_speak.ts`
2. `scripts/alexa_speak.ts` を編集
3. `push-script u/admin/alexa_speak scripts/alexa_speak.ts`
4. Windmill UIで動作確認`device` がドロップダウン表示)
5. API反映後にUIが変わらない場合は `Edit -> Deploy` を1回実行して再確認
関連引き継ぎ文書:
- `docs/flow-manage/11_引き継ぎ_alexa_speak_API反映後にUIドロップダウンが変わらない件.md`
---
## 9. セキュリティ・監査方針
- APIトークンは環境変数またはローカル限定設定で管理
- リポジトリへ平文トークンをコミットしない
- 反映時刻・対象path・実行者をログ化
- サーバーGit Syncは監査証跡として維持
---
## 10. 障害前提の復旧設計(必須)
サーバー内部障害、API非冪等、通信断は防止不能とみなし、**復旧可能性を担保する**。
### 10.1 復旧の基本原則
1. Push前に対象オブジェクトをバックアップ保存必須
2. Push後に `post-verify`(再取得して期待値比較)を必須化
3. Push直前に `preflight hash check` を必須化し、不一致時はPush停止fail closed
4. 失敗時は「再実行」ではなく「現状確認 -> 復旧」の順で実施
### 10.2 標準復旧手順
1. `get` で現状確認存在、content/value、version/hash
2. 期待状態との差分を判定
3. 必要ならバックアップから復元
4. 復元後に `post-verify`
5. 復旧ログを記録日時、path、操作者、原因、処置
### 10.3 フロー欠落時の緊急復旧
`delete -> create` の途中失敗でフローが消失した場合:
1. 直前バックアップJSONで即時 `flows/create`
2. 依存スケジュールの有効性を確認
3. 関連ジョブの手動実行で動作確認
### 10.4 delete -> create 運用ガード(必須)
1. `push-flow` / `push-workflow` 前に対象flowのバックアップJSONを必ず保存
2. Preflightで hash 不一致なら自動Pushしない手動レビューへフォールバック
3. Push後に flow と schedules を再取得し、ローカル期待値と一致確認
4. 不一致時は即時にバックアップから復元し、復旧ログを残す
---
## 11. Windmill依存を薄くする方針必須
Windmill API依存は避けられないため、依存点を最小化する。
### 11.1 使用APIの固定
- `scripts`: `list/get/create`
- `flows`: `list/get/create/delete`
- `schedules`: `list/get/create/delete`workflow package 同期で常用)
上記以外のAPIは原則使わない。
### 11.2 判定ロジックの優先順位
1. 実体JSON比較content/valueの比較を主判定
2. `hash` / `version_id` / `edited_at` は補助判定
3. 補助フィールド欠落時も動作継続できる実装にする
### 11.3 仕様変更検知
- 定期的に `smoke test` を実行してレスポンス形を検証
- 期待フィールド欠落時はPush停止fail closed
- 仕様差分を本ドキュメントの更新履歴に反映
---
## 12. 受け入れ条件
以下を満たせば「このプロジェクトはサーバーのワークフローを管理するためのもの」と言える状態:
1. ローカルから workflow packageflow + schedulesの Pull/Push がAPIで完結
2. サーバー更新が workflow 単位でローカル検知できる
3. 競合時の復旧手順が flow/schedule 両方で定義済み
4. 運用手順がこの文書だけで再現可能
---
## 13. 既知の注意点
1. `wm-api.sh``bash` 前提。WindowsのCRLF混入で shebang 実行失敗し得るため、`.gitattributes``*.sh text eol=lf` を固定し、実行は `bash wm-api.sh ...` を標準とする
2. フロー更新は環境により `PUT` できないため削除再作成を標準とする
3. 削除再作成は `version_id/hash/edited_at` を更新するため、Preflight hash check がないと競合上書きを見落とす可能性がある
4. 1つのflowに複数scheduleが紐づくことがあるため、`script_path` ベースで束ねて管理する
5. API応答仕様はWindmillバージョン差で微差が出るため、初回導入時は `get` のレスポンス形を確認する
---
## 14. 更新履歴
| 日付 | 変更内容 |
|------|----------|
| 2026-03-03 | 初版作成API一本化方針、同期仕様、実装計画、Runbookを定義 |
| 2026-03-03 | 障害前提の復旧設計、Windmill依存を薄くする方針を必須要件として追記 |
| 2026-03-03 | 運用単位を workflow packageflow + schedulesへ変更し、実装計画とRunbookを更新 |
| 2026-03-03 | `delete -> create` と hash 管理の運用ガードpreflight / fail closed / post-verifyおよびCRLF対策を追記 |
| 2026-03-04 | `u/admin/alexa_speak` のAPI反映後にUIドロップダウンが即時反映されない事象と運用回避策`Edit -> Deploy`)を追記 |

24
flows/git_sync.flow.json Normal file
View File

@@ -0,0 +1,24 @@
{
"path": "u/antigravity/git_sync",
"summary": "Git Sync Workflow",
"description": "Automatically sync Windmill workflows to Git repository (sync branch)",
"value": {
"modules": [
{
"id": "a",
"value": {
"lock": "",
"type": "rawscript",
"content": "#!/bin/bash\nset -e\nexport PATH=/usr/bin:/usr/local/bin:/usr/sbin:/sbin:/bin:$PATH\n\nGREEN=\"\\033[0;32m\"\nYELLOW=\"\\033[1;33m\"\nRED=\"\\033[0;31m\"\nNC=\"\\033[0m\"\n\necho -e \"${GREEN}=== Windmill Workflow Git Sync ===${NC}\"\n\nREPO_ROOT=\"/workspace\"\nWMILL_DIR=\"${REPO_ROOT}/workflows\"\n\nif ! command -v wmill &> /dev/null; then\n echo -e \"${YELLOW}Installing windmill-cli...${NC}\"\n npm install -g windmill-cli\n export PATH=$(npm prefix -g)/bin:$PATH\nfi\n\ngit config --global --add safe.directory \"$REPO_ROOT\"\ngit config --global user.email \"bot@keinafarm.net\"\ngit config --global user.name \"Windmill Bot\"\n\n# sync ブランチを使用\nCURRENT_BRANCH=$(git -C \"$REPO_ROOT\" rev-parse --abbrev-ref HEAD)\nif [ \"$CURRENT_BRANCH\" != \"sync\" ]; then\n echo -e \"${YELLOW}Switching to sync branch...${NC}\"\n git -C \"$REPO_ROOT\" fetch origin sync\n git -C \"$REPO_ROOT\" checkout sync\nfi\n\necho -e \"${YELLOW}Pulling from origin/sync...${NC}\"\ngit -C \"$REPO_ROOT\" pull --rebase origin sync || {\n echo -e \"${RED}Failed to pull from remote. Continuing...${NC}\"\n}\n\necho -e \"${YELLOW}Pulling from Windmill...${NC}\"\ncd \"$WMILL_DIR\"\nwmill sync pull --config-dir /workspace/wmill_config --skip-variables --skip-secrets --skip-resources --yes || exit 1\n\ncd \"$REPO_ROOT\"\nif [[ -n $(git status --porcelain) ]]; then\n echo -e \"${YELLOW}Changes detected, committing to Git...${NC}\"\n git add -A\n TIMESTAMP=$(date \"+%Y-%m-%d %H:%M:%S\")\n git commit -m \"Auto-sync: ${TIMESTAMP}\"\n echo -e \"${YELLOW}Pushing to Gitea (sync branch)...${NC}\"\n git push origin sync || {\n echo -e \"${RED}Failed to push.${NC}\"\n exit 1\n }\n echo -e \"${GREEN}Changes pushed to Gitea (sync branch)${NC}\"\nelse\n echo -e \"${GREEN}No changes detected${NC}\"\nfi\n\necho -e \"${GREEN}=== Sync Complete ===${NC}\"\n",
"language": "bash",
"input_transforms": {}
}
}
]
},
"schema": {
"type": "object",
"properties": {},
"required": []
}
}

View File

@@ -0,0 +1,48 @@
{
"path": "u/akiracraftwork/hourly_chime",
"summary": "鳩時計機能",
"description": "毎正時にAlexaで時刻を読み上げる。失敗時はLINEで通知。",
"value": {
"modules": [
{
"id": "a",
"value": {
"lock": "{\n \"dependencies\": {}\n}\n//bun.lock\n<empty>",
"type": "rawscript",
"content": "export async function main(\n device: string = \"オフィスの右エコー\",\n prefix: string = \"現在時刻は\",\n suffix: string = \"です\"\n) {\n const now = new Date();\n const hhmm = new Intl.DateTimeFormat(\"ja-JP\", {\n timeZone: \"Asia/Tokyo\",\n hour: \"2-digit\",\n minute: \"2-digit\",\n hour12: false,\n }).format(now); // 例: 09:30\n\n const [h, m] = hhmm.split(\":\");\n const text = `${prefix}${Number(h)}時${Number(m)}分${suffix}`;\n\n const res = await fetch(\"http://alexa_api:3500/speak\", {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({ device, text }),\n });\n\n if (!res.ok) {\n const body = await res.text();\n throw new Error(`alexa-api error ${res.status}: ${body}`);\n }\n\n return { ok: true, device, text };\n}\n",
"language": "bun",
"input_transforms": {
"device": {
"type": "static",
"value": "オフィスの右エコー"
},
"prefix": {
"type": "static",
"value": "現在時刻は"
},
"suffix": {
"type": "static",
"value": "です"
}
}
}
}
],
"failure_module": {
"id": "failure",
"value": {
"type": "rawscript",
"content": "import * as wmill from \"windmill-client\";\n\nexport async function main() {\n const token = await wmill.getVariable(\"u/admin/LINE_CHANNEL_ACCESS_TOKEN\");\n const to = await wmill.getVariable(\"u/admin/LINE_TO\");\n\n const message = [\n \"\\u26a0\\ufe0f \\u9ce9\\u6642\\u8a08\\u30a8\\u30e9\\u30fc\",\n \"\",\n \"Alexa TTS API \\u304c\\u5931\\u6557\\u3057\\u307e\\u3057\\u305f\\u3002\",\n \"Cookie\\u306e\\u671f\\u9650\\u5207\\u308c\\u306e\\u53ef\\u80fd\\u6027\\u304c\\u3042\\u308a\\u307e\\u3059\\u3002\",\n \"\",\n \"\\u5bfe\\u51e6\\u624b\\u9806:\",\n \"1. \\u30ed\\u30fc\\u30ab\\u30ebPC\\u3067GUI\\u3092\\u8d77\\u52d5\",\n \"cd /home/akira/develop/windmill_workflow/alexa-api\",\n \"npm run auth:web\",\n \"\",\n \"2. \\u30d6\\u30e9\\u30a6\\u30b6\\u3067 http://127.0.0.1:3678 \\u3092\\u958b\\u304f\",\n \"Amazon \\u306e\\u30e1\\u30fc\\u30eb\\u30a2\\u30c9\\u30ec\\u30b9\\u3068\\u30d1\\u30b9\\u30ef\\u30fc\\u30c9\\u3092\\u5165\\u529b\",\n \"\",\n \"3. \\u540c\\u3058GUI\\u306e\\u300c\\u30b5\\u30fc\\u30d0\\u30fc\\u3078\\u53cd\\u6620\\u3059\\u308b\\u300d\\u3092\\u5b9f\\u884c\",\n \"SSH \\u63a5\\u7d9a\\u5148\\u306f keinafarm \\u3067OK\",\n \"\\u521d\\u56de\\u3060\\u3051 /usr/local/bin/alexa-cookie-deploy.sh \\u3068 sudoers \\u8a2d\\u5b9a\\u304c\\u5fc5\\u8981\"\n ].join(\"\\n\");\n\n const res = await fetch(\"https://api.line.me/v2/bot/message/push\", {\n method: \"POST\",\n headers: {\n \"Authorization\": `Bearer ${token}`,\n \"Content-Type\": \"application/json\",\n },\n body: JSON.stringify({\n to: to,\n messages: [{ type: \"text\", text: message }],\n }),\n });\n\n if (!res.ok) {\n const body = await res.text();\n throw new Error(`LINE API error ${res.status}: ${body}`);\n }\n\n return { notified: true };\n}\n",
"language": "bun",
"input_transforms": {}
},
"summary": "エラー時LINE通知"
}
},
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": {},
"required": [],
"type": "object"
}
}

View File

@@ -0,0 +1,24 @@
{
"path": "f/dev/konnnichiha",
"summary": "Print greeting",
"description": "",
"value": {
"modules": [
{
"id": "a",
"value": {
"lock": "# py: 3.12\n",
"type": "rawscript",
"content": "def main():\n print('こんにちは、世界')",
"language": "python3",
"input_transforms": {}
}
}
]
},
"schema": {
"type": "object",
"properties": {},
"required": []
}
}

File diff suppressed because one or more lines are too long

View File

@@ -6,14 +6,14 @@
"modules": [ "modules": [
{ {
"id": "a", "id": "a",
"summary": "変更確認・LINE通知",
"value": { "value": {
"lock": "# py: 3.12\nanyio==4.12.1\ncertifi==2026.1.4\nh11==0.16.0\nhttpcore==1.0.9\nhttpx==0.28.1\nidna==3.11\ntyping-extensions==4.15.0\nwmill==1.640.0",
"type": "rawscript", "type": "rawscript",
"language": "python3",
"content": "import urllib.request\nimport urllib.parse\nimport json\nimport ssl\nfrom datetime import datetime, timezone, timedelta\nimport wmill\n\nJST = timezone(timedelta(hours=9))\n\n\ndef main():\n # シークレット取得\n api_key = wmill.get_variable(\"u/admin/NOTIFICATION_API_KEY\")\n line_token = wmill.get_variable(\"u/admin/LINE_CHANNEL_ACCESS_TOKEN\")\n line_to = wmill.get_variable(\"u/admin/LINE_TO\")\n\n # 前回実行時刻を取得(初回は現在時刻 - 10分\n try:\n last_checked = wmill.get_variable(\"u/admin/SHIRAOU_LAST_CHECKED_AT\")\n if not last_checked:\n last_checked = None\n except Exception:\n last_checked = None\n\n if last_checked:\n since = last_checked\n else:\n since = (datetime.now(JST) - timedelta(minutes=10)).isoformat()\n\n print(f\"[通知] 変更確認: since={since}\")\n\n # API呼び出し\n ssl_ctx = ssl.create_default_context()\n ssl_ctx.check_hostname = False\n ssl_ctx.verify_mode = ssl.CERT_NONE\n\n params = urllib.parse.urlencode({\"since\": since})\n url = f\"https://shiraou.keinafarm.net/reservations/api/changes/?{params}\"\n\n req = urllib.request.Request(url, headers={\"X-API-Key\": api_key})\n with urllib.request.urlopen(req, context=ssl_ctx, timeout=30) as resp:\n data = json.loads(resp.read().decode(\"utf-8\"))\n\n checked_at = data[\"checked_at\"]\n reservations = data.get(\"reservations\", [])\n usages = data.get(\"usages\", [])\n\n print(f\"[通知] checked_at={checked_at}, 予約={len(reservations)}件, 実績={len(usages)}件\")\n\n # 変更があればLINE通知エラー時は状態を更新しない\n if reservations or usages:\n message = _format_message(reservations, usages)\n _send_line(line_token, line_to, message)\n print(\"[通知] LINE送信完了\")\n else:\n print(\"[通知] 変更なし、通知スキップ\")\n\n # 正常完了時のみ状態更新\n wmill.set_variable(\"u/admin/SHIRAOU_LAST_CHECKED_AT\", checked_at)\n print(f\"[通知] last_checked_at更新: {checked_at}\")\n\n return {\n \"since\": since,\n \"checked_at\": checked_at,\n \"reservations_count\": len(reservations),\n \"usages_count\": len(usages),\n \"notified\": bool(reservations or usages),\n }\n\n\ndef _format_message(reservations, usages):\n lines = [\"\\U0001f4cb 営農システム 変更通知\\n\"]\n\n OP_R = {\n \"create\": (\"\\U0001f7e2\", \"予約作成\"),\n \"update\": (\"\\U0001f535\", \"予約変更\"),\n \"cancel\": (\"\\U0001f534\", \"予約キャンセル\"),\n }\n OP_U = {\n \"create\": (\"\\U0001f7e2\", \"実績登録\"),\n \"update\": (\"\\U0001f535\", \"実績修正\"),\n \"delete\": (\"\\U0001f534\", \"実績削除\"),\n }\n\n for r in reservations:\n start = r[\"start_at\"][:16].replace(\"T\", \" \")\n end = r[\"end_at\"][:16].replace(\"T\", \" \")\n icon, label = OP_R.get(r[\"operation\"], (\"\\u26aa\", r[\"operation\"]))\n lines += [\n f\"{icon} {label}\",\n f\" 機械: {r['machine_name']}\",\n f\" 利用者: {r['user_name']}\",\n f\" 日時: {start} \\uff5e {end}\",\n ]\n if r.get(\"reason\"):\n lines.append(f\" 理由: {r['reason']}\")\n lines.append(\"\")\n\n for u in usages:\n start = u[\"start_at\"][:16].replace(\"T\", \" \")\n icon, label = OP_U.get(u[\"operation\"], (\"\\u26aa\", u[\"operation\"]))\n lines += [\n f\"{icon} {label}\",\n f\" 機械: {u['machine_name']}\",\n f\" 利用者: {u['user_name']}\",\n f\" 利用量: {u['amount']}{u['unit']}\",\n f\" 日: {start[:10]}\",\n ]\n if u.get(\"reason\"):\n lines.append(f\" 理由: {u['reason']}\")\n lines.append(\"\")\n\n return \"\\n\".join(lines).strip()\n\n\ndef _send_line(token, to, message):\n payload = json.dumps({\n \"to\": to,\n \"messages\": [{\"type\": \"text\", \"text\": message}],\n }).encode(\"utf-8\")\n\n req = urllib.request.Request(\n \"https://api.line.me/v2/bot/message/push\",\n data=payload,\n headers={\n \"Authorization\": f\"Bearer {token}\",\n \"Content-Type\": \"application/json\",\n },\n method=\"POST\",\n )\n with urllib.request.urlopen(req, timeout=30) as resp:\n return resp.read().decode(\"utf-8\")\n", "content": "import urllib.request\nimport urllib.parse\nimport json\nimport ssl\nfrom datetime import datetime, timezone, timedelta\nimport wmill\n\nJST = timezone(timedelta(hours=9))\n\n\ndef main():\n # シークレット取得\n api_key = wmill.get_variable(\"u/admin/NOTIFICATION_API_KEY\")\n line_token = wmill.get_variable(\"u/admin/LINE_CHANNEL_ACCESS_TOKEN\")\n line_to = wmill.get_variable(\"u/admin/LINE_TO\")\n\n # 前回実行時刻を取得(初回は現在時刻 - 10分\n try:\n last_checked = wmill.get_variable(\"u/admin/SHIRAOU_LAST_CHECKED_AT\")\n if not last_checked:\n last_checked = None\n except Exception:\n last_checked = None\n\n if last_checked:\n since = last_checked\n else:\n since = (datetime.now(JST) - timedelta(minutes=10)).isoformat()\n\n print(f\"[通知] 変更確認: since={since}\")\n\n # API呼び出し\n ssl_ctx = ssl.create_default_context()\n ssl_ctx.check_hostname = False\n ssl_ctx.verify_mode = ssl.CERT_NONE\n\n params = urllib.parse.urlencode({\"since\": since})\n url = f\"https://shiraou.keinafarm.net/reservations/api/changes/?{params}\"\n\n req = urllib.request.Request(url, headers={\"X-API-Key\": api_key})\n with urllib.request.urlopen(req, context=ssl_ctx, timeout=30) as resp:\n data = json.loads(resp.read().decode(\"utf-8\"))\n\n checked_at = data[\"checked_at\"]\n reservations = data.get(\"reservations\", [])\n usages = data.get(\"usages\", [])\n\n print(f\"[通知] checked_at={checked_at}, 予約={len(reservations)}件, 実績={len(usages)}件\")\n\n # 変更があればLINE通知エラー時は状態を更新しない\n if reservations or usages:\n message = _format_message(reservations, usages)\n _send_line(line_token, line_to, message)\n print(\"[通知] LINE送信完了\")\n else:\n print(\"[通知] 変更なし、通知スキップ\")\n\n # 正常完了時のみ状態更新\n wmill.set_variable(\"u/admin/SHIRAOU_LAST_CHECKED_AT\", checked_at)\n print(f\"[通知] last_checked_at更新: {checked_at}\")\n\n return {\n \"since\": since,\n \"checked_at\": checked_at,\n \"reservations_count\": len(reservations),\n \"usages_count\": len(usages),\n \"notified\": bool(reservations or usages),\n }\n\n\ndef _format_message(reservations, usages):\n lines = [\"\\U0001f4cb 営農システム 変更通知\\n\"]\n\n OP_R = {\n \"create\": (\"\\U0001f7e2\", \"予約作成\"),\n \"update\": (\"\\U0001f535\", \"予約変更\"),\n \"cancel\": (\"\\U0001f534\", \"予約キャンセル\"),\n }\n OP_U = {\n \"create\": (\"\\U0001f7e2\", \"実績登録\"),\n \"update\": (\"\\U0001f535\", \"実績修正\"),\n \"delete\": (\"\\U0001f534\", \"実績削除\"),\n }\n\n for r in reservations:\n start = r[\"start_at\"][:16].replace(\"T\", \" \")\n end = r[\"end_at\"][:16].replace(\"T\", \" \")\n icon, label = OP_R.get(r[\"operation\"], (\"\\u26aa\", r[\"operation\"]))\n lines += [\n f\"{icon} {label}\",\n f\" 機械: {r['machine_name']}\",\n f\" 利用者: {r['user_name']}\",\n f\" 日時: {start} \\uff5e {end}\",\n ]\n if r.get(\"reason\"):\n lines.append(f\" 理由: {r['reason']}\")\n lines.append(\"\")\n\n for u in usages:\n start = u[\"start_at\"][:16].replace(\"T\", \" \")\n icon, label = OP_U.get(u[\"operation\"], (\"\\u26aa\", u[\"operation\"]))\n lines += [\n f\"{icon} {label}\",\n f\" 機械: {u['machine_name']}\",\n f\" 利用者: {u['user_name']}\",\n f\" 利用量: {u['amount']}{u['unit']}\",\n f\" 日: {start[:10]}\",\n ]\n if u.get(\"reason\"):\n lines.append(f\" 理由: {u['reason']}\")\n lines.append(\"\")\n\n return \"\\n\".join(lines).strip()\n\n\ndef _send_line(token, to, message):\n payload = json.dumps({\n \"to\": to,\n \"messages\": [{\"type\": \"text\", \"text\": message}],\n }).encode(\"utf-8\")\n\n req = urllib.request.Request(\n \"https://api.line.me/v2/bot/message/push\",\n data=payload,\n headers={\n \"Authorization\": f\"Bearer {token}\",\n \"Content-Type\": \"application/json\",\n },\n method=\"POST\",\n )\n with urllib.request.urlopen(req, timeout=30) as resp:\n return resp.read().decode(\"utf-8\")\n",
"input_transforms": {}, "language": "python3",
"lock": "" "input_transforms": {}
} },
"summary": "変更確認・LINE通知"
} }
] ]
}, },

View File

@@ -6,70 +6,70 @@
"modules": [ "modules": [
{ {
"id": "a", "id": "a",
"summary": "Step1: 診断データ生成",
"value": { "value": {
"lock": "# py: 3.12\n",
"type": "rawscript", "type": "rawscript",
"language": "python3",
"content": "import uuid\nfrom datetime import datetime, timezone\n\ndef main():\n \"\"\"診断データを生成する\"\"\"\n now = datetime.now(timezone.utc)\n run_id = str(uuid.uuid4())\n check_value = 2 + 2\n \n result = {\n \"timestamp\": now.isoformat(),\n \"run_id\": run_id,\n \"check\": check_value,\n \"python_version\": __import__('sys').version\n }\n print(f\"[Step1] 診断データ生成完了\")\n print(f\" run_id: {run_id}\")\n print(f\" timestamp: {now.isoformat()}\")\n print(f\" check: {check_value}\")\n return result\n", "content": "import uuid\nfrom datetime import datetime, timezone\n\ndef main():\n \"\"\"診断データを生成する\"\"\"\n now = datetime.now(timezone.utc)\n run_id = str(uuid.uuid4())\n check_value = 2 + 2\n \n result = {\n \"timestamp\": now.isoformat(),\n \"run_id\": run_id,\n \"check\": check_value,\n \"python_version\": __import__('sys').version\n }\n print(f\"[Step1] 診断データ生成完了\")\n print(f\" run_id: {run_id}\")\n print(f\" timestamp: {now.isoformat()}\")\n print(f\" check: {check_value}\")\n return result\n",
"input_transforms": {}, "language": "python3",
"lock": "" "input_transforms": {}
} },
"summary": "Step1: 診断データ生成"
}, },
{ {
"id": "b", "id": "b",
"summary": "Step2: データ検証",
"value": { "value": {
"lock": "# py: 3.12\n",
"type": "rawscript", "type": "rawscript",
"language": "python3",
"content": "from datetime import datetime, timezone\n\ndef main(step1_result: dict):\n \"\"\"Step1の結果を検証する\"\"\"\n errors = []\n \n # 計算チェック\n if step1_result.get(\"check\") != 4:\n errors.append(f\"計算エラー: expected 4, got {step1_result.get('check')}\")\n \n # run_idの存在チェック\n if not step1_result.get(\"run_id\"):\n errors.append(\"run_idが存在しない\")\n \n # timestampの存在チェック\n if not step1_result.get(\"timestamp\"):\n errors.append(\"timestampが存在しない\")\n \n if errors:\n error_msg = \"; \".join(errors)\n print(f\"[Step2] 検証失敗: {error_msg}\")\n raise Exception(f\"検証失敗: {error_msg}\")\n \n print(f\"[Step2] データ検証OK\")\n print(f\" 計算チェック: 2+2={step1_result['check']} ✓\")\n print(f\" run_id: {step1_result['run_id']} ✓\")\n print(f\" timestamp: {step1_result['timestamp']} ✓\")\n \n return {\n \"verification\": \"PASS\",\n \"step1_data\": step1_result\n }\n", "content": "from datetime import datetime, timezone\n\ndef main(step1_result: dict):\n \"\"\"Step1の結果を検証する\"\"\"\n errors = []\n \n # 計算チェック\n if step1_result.get(\"check\") != 4:\n errors.append(f\"計算エラー: expected 4, got {step1_result.get('check')}\")\n \n # run_idの存在チェック\n if not step1_result.get(\"run_id\"):\n errors.append(\"run_idが存在しない\")\n \n # timestampの存在チェック\n if not step1_result.get(\"timestamp\"):\n errors.append(\"timestampが存在しない\")\n \n if errors:\n error_msg = \"; \".join(errors)\n print(f\"[Step2] 検証失敗: {error_msg}\")\n raise Exception(f\"検証失敗: {error_msg}\")\n \n print(f\"[Step2] データ検証OK\")\n print(f\" 計算チェック: 2+2={step1_result['check']} ✓\")\n print(f\" run_id: {step1_result['run_id']} ✓\")\n print(f\" timestamp: {step1_result['timestamp']} ✓\")\n \n return {\n \"verification\": \"PASS\",\n \"step1_data\": step1_result\n }\n",
"language": "python3",
"input_transforms": { "input_transforms": {
"step1_result": { "step1_result": {
"type": "javascript", "expr": "results.a",
"expr": "results.a" "type": "javascript"
}
} }
}, },
"lock": "" "summary": "Step2: データ検証"
}
}, },
{ {
"id": "c", "id": "c",
"summary": "Step3: HTTPヘルスチェック",
"value": { "value": {
"lock": "# py: 3.12\n",
"type": "rawscript", "type": "rawscript",
"language": "python3",
"content": "import urllib.request\nimport ssl\n\ndef main(verification_result: dict):\n \"\"\"Windmillサーバー自身へのHTTPチェック\"\"\"\n url = \"https://windmill.keinafarm.net/api/version\"\n \n # SSL検証をスキップ自己署名証明書対応\n ctx = ssl.create_default_context()\n ctx.check_hostname = False\n ctx.verify_mode = ssl.CERT_NONE\n \n try:\n req = urllib.request.Request(url)\n with urllib.request.urlopen(req, context=ctx, timeout=10) as response:\n status_code = response.status\n body = response.read().decode('utf-8')\n except Exception as e:\n print(f\"[Step3] HTTPチェック失敗: {e}\")\n raise Exception(f\"HTTPヘルスチェック失敗: {e}\")\n \n print(f\"[Step3] HTTPヘルスチェックOK\")\n print(f\" URL: {url}\")\n print(f\" Status: {status_code}\")\n print(f\" Version: {body}\")\n \n return {\n \"http_check\": \"PASS\",\n \"status_code\": status_code,\n \"server_version\": body\n }\n", "content": "import urllib.request\nimport ssl\n\ndef main(verification_result: dict):\n \"\"\"Windmillサーバー自身へのHTTPチェック\"\"\"\n url = \"https://windmill.keinafarm.net/api/version\"\n \n # SSL検証をスキップ自己署名証明書対応\n ctx = ssl.create_default_context()\n ctx.check_hostname = False\n ctx.verify_mode = ssl.CERT_NONE\n \n try:\n req = urllib.request.Request(url)\n with urllib.request.urlopen(req, context=ctx, timeout=10) as response:\n status_code = response.status\n body = response.read().decode('utf-8')\n except Exception as e:\n print(f\"[Step3] HTTPチェック失敗: {e}\")\n raise Exception(f\"HTTPヘルスチェック失敗: {e}\")\n \n print(f\"[Step3] HTTPヘルスチェックOK\")\n print(f\" URL: {url}\")\n print(f\" Status: {status_code}\")\n print(f\" Version: {body}\")\n \n return {\n \"http_check\": \"PASS\",\n \"status_code\": status_code,\n \"server_version\": body\n }\n",
"language": "python3",
"input_transforms": { "input_transforms": {
"verification_result": { "verification_result": {
"type": "javascript", "expr": "results.b",
"expr": "results.b" "type": "javascript"
}
} }
}, },
"lock": "" "summary": "Step3: HTTPヘルスチェック"
}
}, },
{ {
"id": "d", "id": "d",
"summary": "Step4: 年度判定 & 最終レポート",
"value": { "value": {
"lock": "# py: 3.12\n",
"type": "rawscript", "type": "rawscript",
"language": "python3",
"content": "from datetime import datetime, timezone\n\ndef main(step1_data: dict, verification: dict, http_check: dict):\n \"\"\"年度判定と最終診断レポートを生成\"\"\"\n now = datetime.now(timezone.utc)\n \n # 日本の年度判定4月始まり\n fiscal_year = now.year if now.month >= 4 else now.year - 1\n \n report = {\n \"status\": \"ALL OK\",\n \"fiscal_year\": fiscal_year,\n \"diagnostics\": {\n \"data_generation\": \"PASS\",\n \"data_verification\": verification.get(\"verification\", \"UNKNOWN\"),\n \"http_health\": http_check.get(\"http_check\", \"UNKNOWN\"),\n \"server_version\": http_check.get(\"server_version\", \"UNKNOWN\")\n },\n \"run_id\": step1_data.get(\"run_id\"),\n \"started_at\": step1_data.get(\"timestamp\"),\n \"completed_at\": now.isoformat()\n }\n \n print(\"\")\n print(\"========================================\")\n print(\" Windmill Heartbeat - 診断レポート\")\n print(\"========================================\")\n print(f\" Status: {report['status']}\")\n print(f\" 年度: {fiscal_year}年度\")\n print(f\" Run ID: {report['run_id']}\")\n print(f\" Server: {report['diagnostics']['server_version']}\")\n print(f\" 開始: {report['started_at']}\")\n print(f\" 完了: {report['completed_at']}\")\n print(\" ────────────────────────────────────\")\n print(f\" データ生成: PASS ✓\")\n print(f\" データ検証: {report['diagnostics']['data_verification']} ✓\")\n print(f\" HTTP確認: {report['diagnostics']['http_health']} ✓\")\n print(\"========================================\")\n print(\"\")\n \n return report\n", "content": "from datetime import datetime, timezone\n\ndef main(step1_data: dict, verification: dict, http_check: dict):\n \"\"\"年度判定と最終診断レポートを生成\"\"\"\n now = datetime.now(timezone.utc)\n \n # 日本の年度判定4月始まり\n fiscal_year = now.year if now.month >= 4 else now.year - 1\n \n report = {\n \"status\": \"ALL OK\",\n \"fiscal_year\": fiscal_year,\n \"diagnostics\": {\n \"data_generation\": \"PASS\",\n \"data_verification\": verification.get(\"verification\", \"UNKNOWN\"),\n \"http_health\": http_check.get(\"http_check\", \"UNKNOWN\"),\n \"server_version\": http_check.get(\"server_version\", \"UNKNOWN\")\n },\n \"run_id\": step1_data.get(\"run_id\"),\n \"started_at\": step1_data.get(\"timestamp\"),\n \"completed_at\": now.isoformat()\n }\n \n print(\"\")\n print(\"========================================\")\n print(\" Windmill Heartbeat - 診断レポート\")\n print(\"========================================\")\n print(f\" Status: {report['status']}\")\n print(f\" 年度: {fiscal_year}年度\")\n print(f\" Run ID: {report['run_id']}\")\n print(f\" Server: {report['diagnostics']['server_version']}\")\n print(f\" 開始: {report['started_at']}\")\n print(f\" 完了: {report['completed_at']}\")\n print(\" ────────────────────────────────────\")\n print(f\" データ生成: PASS ✓\")\n print(f\" データ検証: {report['diagnostics']['data_verification']} ✓\")\n print(f\" HTTP確認: {report['diagnostics']['http_health']} ✓\")\n print(\"========================================\")\n print(\"\")\n \n return report\n",
"language": "python3",
"input_transforms": { "input_transforms": {
"http_check": {
"expr": "results.c",
"type": "javascript"
},
"step1_data": { "step1_data": {
"type": "javascript", "expr": "results.a",
"expr": "results.a" "type": "javascript"
}, },
"verification": { "verification": {
"type": "javascript", "expr": "results.b",
"expr": "results.b" "type": "javascript"
}, }
"http_check": {
"type": "javascript",
"expr": "results.c"
} }
}, },
"lock": "" "summary": "Step4: 年度判定 & 最終レポート"
}
} }
] ]
}, },

24
flows/textout.flow.json Normal file
View File

@@ -0,0 +1,24 @@
{
"path": "f/dev/textout",
"summary": "Display current time on startup",
"description": "",
"value": {
"modules": [
{
"id": "a",
"value": {
"lock": "# py: 3.12\n",
"type": "rawscript",
"content": "def main():\n from datetime import datetime\n print(datetime.now().strftime('%H:%M:%S'))",
"language": "python3",
"input_transforms": {}
}
}
]
},
"schema": {
"type": "object",
"properties": {},
"required": []
}
}

View File

@@ -0,0 +1,27 @@
{
"path": "f/weather/weather_sync",
"summary": "Weather Sync - 気象データ日次同期",
"description": "Open-Meteo から昨日の気象データを取得し、Keinasystem DB に保存する。毎朝6時実行。",
"value": {
"modules": [
{
"id": "a",
"value": {
"lock": "# py: 3.12\nanyio==4.12.1\ncertifi==2026.2.25\ncharset-normalizer==3.4.4\nh11==0.16.0\nhttpcore==1.0.9\nhttpx==0.28.1\nidna==3.11\nrequests==2.32.5\ntyping-extensions==4.15.0\nurllib3==2.6.3\nwmill==1.646.0",
"type": "rawscript",
"content": "import wmill\nimport requests\nimport datetime\n\nLATITUDE = 33.213\nLONGITUDE = 133.133\nTIMEZONE = \"Asia/Tokyo\"\n\nOPEN_METEO_URL = \"https://archive-api.open-meteo.com/v1/archive\"\nDAILY_VARS = [\n \"temperature_2m_mean\",\n \"temperature_2m_max\",\n \"temperature_2m_min\",\n \"sunshine_duration\",\n \"precipitation_sum\",\n \"wind_speed_10m_max\",\n \"surface_pressure_min\",\n]\n\n\ndef main():\n api_key = wmill.get_variable(\"u/admin/KEINASYSTEM_API_KEY\")\n base_url = wmill.get_variable(\"u/admin/KEINASYSTEM_API_URL\").rstrip(\"/\")\n sync_url = f\"{base_url}/api/weather/sync/\"\n\n yesterday = (datetime.date.today() - datetime.timedelta(days=1)).isoformat()\n print(f\"Fetching weather data for {yesterday} ...\")\n\n params = {\n \"latitude\": LATITUDE,\n \"longitude\": LONGITUDE,\n \"start_date\": yesterday,\n \"end_date\": yesterday,\n \"daily\": DAILY_VARS,\n \"timezone\": TIMEZONE,\n }\n resp = requests.get(OPEN_METEO_URL, params=params, timeout=30)\n if resp.status_code != 200:\n raise Exception(f\"Open-Meteo API error: {resp.status_code} {resp.text[:300]}\")\n\n daily = resp.json().get(\"daily\", {})\n dates = daily.get(\"time\", [])\n if not dates:\n print(\"No data returned from Open-Meteo.\")\n return {\"status\": \"no_data\"}\n\n sunshine_raw = daily.get(\"sunshine_duration\", [])\n records = []\n for i, d in enumerate(dates):\n sun_sec = sunshine_raw[i]\n records.append({\n \"date\": d,\n \"temp_mean\": daily[\"temperature_2m_mean\"][i],\n \"temp_max\": daily[\"temperature_2m_max\"][i],\n \"temp_min\": daily[\"temperature_2m_min\"][i],\n \"sunshine_h\": round(sun_sec / 3600, 2) if sun_sec is not None else None,\n \"precip_mm\": daily[\"precipitation_sum\"][i],\n \"wind_max\": daily[\"wind_speed_10m_max\"][i],\n \"pressure_min\": daily[\"surface_pressure_min\"][i],\n })\n\n headers = {\n \"X-API-Key\": api_key,\n \"Content-Type\": \"application/json\",\n }\n post_resp = requests.post(sync_url, json=records, headers=headers, timeout=30)\n if post_resp.status_code not in (200, 201):\n raise Exception(f\"Keinasystem sync error: {post_resp.status_code} {post_resp.text[:300]}\")\n\n result = post_resp.json()\n print(f\"Sync complete: {result}\")\n return result\n",
"language": "python3",
"input_transforms": {}
},
"summary": "気象データ取得・同期"
}
]
},
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"order": [],
"properties": {},
"required": []
}
}

View File

@@ -1,12 +1,3 @@
/**
* alexa_speak.ts
* 指定した Echo デバイスにテキストを読み上げさせる Windmill スクリプト
*
* パラメータ:
* device - デバイス名またはシリアル番号(例: "リビングエコー1", "プレハブ"
* text - 読み上げるテキスト
*/
export async function main( export async function main(
device: string, device: string,
text: string, text: string,
@@ -16,15 +7,14 @@ export async function main(
const res = await fetch(`${ALEXA_API_URL}/speak`, { const res = await fetch(`${ALEXA_API_URL}/speak`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ device, text }), body: JSON.stringify({ device, text }), // ← SSMLなし、素のテキスト
}); });
if (!res.ok) { if (!res.ok) {
const body = await res.json().catch(() => ({})); const body = await res.json().catch(() => ({}));
throw new Error( throw new Error(`alexa-api error ${res.status}: ${JSON.stringify(body)}`);
`alexa-api error ${res.status}: ${JSON.stringify(body)}`
);
} }
return await res.json(); return await res.json();
} }

106
state/flows.list.json Normal file
View File

@@ -0,0 +1,106 @@
[
{
"workspace_id": "admins",
"path": "u/akiracraftwork/hourly_chime",
"summary": "鳩時計機能",
"description": "",
"edited_by": "akiracraftwork@gmail.com",
"edited_at": "2026-03-03T05:37:39.969305Z",
"archived": false,
"extra_perms": {},
"starred": false,
"has_draft": false,
"ws_error_handler_muted": false
},
{
"workspace_id": "admins",
"path": "f/dev/textout",
"summary": "Display current time on startup",
"description": "",
"edited_by": "akiracraftwork@gmail.com",
"edited_at": "2026-03-02T05:05:05.215985Z",
"archived": false,
"extra_perms": {},
"starred": false,
"has_draft": false,
"ws_error_handler_muted": false
},
{
"workspace_id": "admins",
"path": "f/dev/konnnichiha",
"summary": "Print greeting",
"description": "",
"edited_by": "akiracraftwork@gmail.com",
"edited_at": "2026-03-02T04:53:56.968574Z",
"archived": false,
"extra_perms": {},
"starred": false,
"has_draft": false,
"ws_error_handler_muted": false
},
{
"workspace_id": "admins",
"path": "u/antigravity/git_sync",
"summary": "Git Sync Workflow",
"description": "Automatically sync Windmill workflows to Git repository (sync branch)",
"edited_by": "akiracraftwork@gmail.com",
"edited_at": "2026-03-01T17:28:14.331046Z",
"archived": false,
"extra_perms": {},
"starred": false,
"has_draft": false,
"ws_error_handler_muted": false
},
{
"workspace_id": "admins",
"path": "f/weather/weather_sync",
"summary": "Weather Sync - 気象データ日次同期",
"description": "Open-Meteo から昨日の気象データを取得し、Keinasystem DB に保存する。毎朝6時実行。",
"edited_by": "akiracraftwork@gmail.com",
"edited_at": "2026-02-28T04:31:27.835748Z",
"archived": false,
"extra_perms": {},
"starred": false,
"has_draft": false,
"ws_error_handler_muted": false
},
{
"workspace_id": "admins",
"path": "f/mail/mail_filter",
"summary": "メールフィルタリング",
"description": "IMAPで新着メールを受信し、送信者ルール確認→LLM判定→LINE通知を行う。Keinasystemと連携。Gmail→Hotmail→Xserverの順で段階的に有効化する。",
"edited_by": "akiracraftwork@gmail.com",
"edited_at": "2026-02-24T06:41:54.748865Z",
"archived": false,
"extra_perms": {},
"starred": false,
"has_draft": false,
"ws_error_handler_muted": false
},
{
"workspace_id": "admins",
"path": "f/shiraou/shiraou_notification",
"summary": "白皇集落営農 変更通知",
"description": "shiraou.keinafarm.net の予約・実績変更をポーリングし、変更があればLINEで管理者に通知する。5分毎に実行。",
"edited_by": "akiracraftwork@gmail.com",
"edited_at": "2026-02-21T06:33:11.078673Z",
"archived": false,
"extra_perms": {},
"starred": false,
"has_draft": false,
"ws_error_handler_muted": false
},
{
"workspace_id": "admins",
"path": "f/app_custom/system_heartbeat",
"summary": "Windmill Heartbeat - システム自己診断",
"description": "Windmillの動作確認用ワークフロー。UUID生成、時刻取得、計算チェック、HTTPヘルスチェック、年度判定を行い、全ステップの正常性を検証する。",
"edited_by": "akiracraftwork@gmail.com",
"edited_at": "2026-02-21T03:43:55.495111Z",
"archived": false,
"extra_perms": {},
"starred": false,
"has_draft": false,
"ws_error_handler_muted": false
}
]

View File

@@ -0,0 +1,78 @@
{
"synced_at": "2026-03-03T07:09:55Z",
"workspace": "admins",
"scripts": {
"u/admin/alexa_speak": {
"hash": "3783872112d1a24c",
"updated_at": "2026-03-03T02:57:13.068287Z"
}
},
"flows": {
"u/akiracraftwork/hourly_chime": {
"updated_at": "2026-03-03T05:37:39.969305Z"
},
"f/dev/textout": {
"updated_at": "2026-03-02T05:05:05.215985Z"
},
"f/dev/konnnichiha": {
"updated_at": "2026-03-02T04:53:56.968574Z"
},
"u/antigravity/git_sync": {
"updated_at": "2026-03-01T17:28:14.331046Z"
},
"f/weather/weather_sync": {
"updated_at": "2026-02-28T04:31:27.835748Z"
},
"f/mail/mail_filter": {
"updated_at": "2026-02-24T06:41:54.748865Z"
},
"f/shiraou/shiraou_notification": {
"updated_at": "2026-02-21T06:33:11.078673Z"
},
"f/app_custom/system_heartbeat": {
"updated_at": "2026-02-21T03:43:55.495111Z"
}
},
"schedules": {
"u/akiracraftwork/hourly_chime": {
"schedule": "0 0 * * * *",
"timezone": "Asia/Tokyo",
"enabled": true,
"script_path": "u/akiracraftwork/hourly_chime",
"is_flow": true,
"updated_at": "2026-03-03T04:44:03.309346Z"
},
"f/weather/weather_sync": {
"schedule": "0 0 6 * * *",
"timezone": "Asia/Tokyo",
"enabled": true,
"script_path": "f/weather/weather_sync",
"is_flow": true,
"updated_at": "2026-02-28T04:31:41.375049Z"
},
"f/mail/mail_filter_schedule": {
"schedule": "0 */10 * * * *",
"timezone": "Asia/Tokyo",
"enabled": true,
"script_path": "f/mail/mail_filter",
"is_flow": true,
"updated_at": "2026-02-24T06:42:06.977249Z"
},
"f/shiraou/shiraou_notification_every_5min": {
"schedule": "0 */5 * * * *",
"timezone": "Asia/Tokyo",
"enabled": true,
"script_path": "f/shiraou/shiraou_notification",
"is_flow": true,
"updated_at": "2026-02-21T06:18:34.967961Z"
},
"u/antigravity/git_sync": {
"schedule": "0 */30 * * * *",
"timezone": "Asia/Tokyo",
"enabled": true,
"script_path": "u/antigravity/git_sync",
"is_flow": true,
"updated_at": "2026-02-19T06:38:19.867037Z"
}
}
}

78
state/remote_index.json Normal file
View File

@@ -0,0 +1,78 @@
{
"synced_at": "2026-03-03T07:12:56Z",
"workspace": "admins",
"scripts": {
"u/admin/alexa_speak": {
"hash": "3783872112d1a24c",
"updated_at": "2026-03-03T02:57:13.068287Z"
}
},
"flows": {
"u/akiracraftwork/hourly_chime": {
"updated_at": "2026-03-03T05:37:39.969305Z"
},
"f/dev/textout": {
"updated_at": "2026-03-02T05:05:05.215985Z"
},
"f/dev/konnnichiha": {
"updated_at": "2026-03-02T04:53:56.968574Z"
},
"u/antigravity/git_sync": {
"updated_at": "2026-03-01T17:28:14.331046Z"
},
"f/weather/weather_sync": {
"updated_at": "2026-02-28T04:31:27.835748Z"
},
"f/mail/mail_filter": {
"updated_at": "2026-02-24T06:41:54.748865Z"
},
"f/shiraou/shiraou_notification": {
"updated_at": "2026-02-21T06:33:11.078673Z"
},
"f/app_custom/system_heartbeat": {
"updated_at": "2026-02-21T03:43:55.495111Z"
}
},
"schedules": {
"u/akiracraftwork/hourly_chime": {
"schedule": "0 0 * * * *",
"timezone": "Asia/Tokyo",
"enabled": true,
"script_path": "u/akiracraftwork/hourly_chime",
"is_flow": true,
"updated_at": "2026-03-03T04:44:03.309346Z"
},
"f/weather/weather_sync": {
"schedule": "0 0 6 * * *",
"timezone": "Asia/Tokyo",
"enabled": true,
"script_path": "f/weather/weather_sync",
"is_flow": true,
"updated_at": "2026-02-28T04:31:41.375049Z"
},
"f/mail/mail_filter_schedule": {
"schedule": "0 */10 * * * *",
"timezone": "Asia/Tokyo",
"enabled": true,
"script_path": "f/mail/mail_filter",
"is_flow": true,
"updated_at": "2026-02-24T06:42:06.977249Z"
},
"f/shiraou/shiraou_notification_every_5min": {
"schedule": "0 */5 * * * *",
"timezone": "Asia/Tokyo",
"enabled": true,
"script_path": "f/shiraou/shiraou_notification",
"is_flow": true,
"updated_at": "2026-02-21T06:18:34.967961Z"
},
"u/antigravity/git_sync": {
"schedule": "0 */30 * * * *",
"timezone": "Asia/Tokyo",
"enabled": true,
"script_path": "u/antigravity/git_sync",
"is_flow": true,
"updated_at": "2026-02-19T06:38:19.867037Z"
}
}
}

67
state/schedules.list.json Normal file
View File

@@ -0,0 +1,67 @@
[
{
"workspace_id": "admins",
"path": "u/akiracraftwork/hourly_chime",
"edited_by": "akiracraftwork@gmail.com",
"edited_at": "2026-03-03T04:44:03.309346Z",
"schedule": "0 0 * * * *",
"timezone": "Asia/Tokyo",
"enabled": true,
"script_path": "u/akiracraftwork/hourly_chime",
"is_flow": true,
"summary": null,
"extra_perms": {}
},
{
"workspace_id": "admins",
"path": "f/weather/weather_sync",
"edited_by": "akiracraftwork@gmail.com",
"edited_at": "2026-02-28T04:31:41.375049Z",
"schedule": "0 0 6 * * *",
"timezone": "Asia/Tokyo",
"enabled": true,
"script_path": "f/weather/weather_sync",
"is_flow": true,
"summary": null,
"extra_perms": {}
},
{
"workspace_id": "admins",
"path": "f/mail/mail_filter_schedule",
"edited_by": "akiracraftwork@gmail.com",
"edited_at": "2026-02-24T06:42:06.977249Z",
"schedule": "0 */10 * * * *",
"timezone": "Asia/Tokyo",
"enabled": true,
"script_path": "f/mail/mail_filter",
"is_flow": true,
"summary": null,
"extra_perms": {}
},
{
"workspace_id": "admins",
"path": "f/shiraou/shiraou_notification_every_5min",
"edited_by": "akiracraftwork@gmail.com",
"edited_at": "2026-02-21T06:18:34.967961Z",
"schedule": "0 */5 * * * *",
"timezone": "Asia/Tokyo",
"enabled": true,
"script_path": "f/shiraou/shiraou_notification",
"is_flow": true,
"summary": null,
"extra_perms": {}
},
{
"workspace_id": "admins",
"path": "u/antigravity/git_sync",
"edited_by": "akiracraftwork@gmail.com",
"edited_at": "2026-02-19T06:38:19.867037Z",
"schedule": "0 */30 * * * *",
"timezone": "Asia/Tokyo",
"enabled": true,
"script_path": "u/antigravity/git_sync",
"is_flow": true,
"summary": null,
"extra_perms": {}
}
]

18
state/scripts.list.json Normal file
View File

@@ -0,0 +1,18 @@
[
{
"hash": "3783872112d1a24c",
"path": "u/admin/alexa_speak",
"summary": "Echo デバイスに TTS で読み上げ",
"created_at": "2026-03-03T02:57:13.068287Z",
"archived": false,
"extra_perms": {},
"language": "bun",
"starred": false,
"tag": null,
"description": "指定した Echo デバイスにテキストを読み上げさせる",
"has_draft": false,
"has_deploy_errors": false,
"ws_error_handler_muted": false,
"kind": "script"
}
]

962
wm-api.sh

File diff suppressed because it is too large Load Diff