Compare commits

...

15 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
56 changed files with 8615 additions and 7145 deletions

View File

@@ -11,7 +11,10 @@
"Bash(printf:*)",
"Bash(~/.git-credentials)",
"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 フローを正しく再現するカスタム認証スクリプト
* alexa-cookie2 の古いエンドポイント問題を回避して直接フォームを処理する
* auth4.js - CLI 版の Alexa Cookie 更新
*/
const https = require('https');
const fs = require('fs');
const path = require('path');
const EMAIL = process.env.AMAZON_EMAIL;
const PASSWORD = process.env.AMAZON_PASSWORD;
const { fetchAlexaCookieAndSave } = require('./auth4-core');
if (!EMAIL || !PASSWORD) {
console.error('[ERROR] 環境変数 AMAZON_EMAIL と AMAZON_PASSWORD を設定してください');
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() {
console.log('[1] ログインページ取得中...');
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,
const result = await fetchAlexaCookieAndSave({
email: EMAIL,
password: PASSWORD,
rememberMe: 'true',
}).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,
logger: console.log,
});
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(' 認証成功!');
console.log('==============================================');
console.log(`.env を保存しました: ${envPath}`);
console.log(`Cookie 長さ: ${cookie.length} 文字`);
console.log(`.env を保存しました: ${result.envPath}`);
console.log(`Cookie 長さ: ${result.cookieLength} 文字`);
}
main().catch((err) => {

View File

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

1
butler.pid Normal file
View File

@@ -0,0 +1 @@
34292

View File

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

View File

@@ -1,9 +1,11 @@
# マスタードキュメント - Alexa TTS API 編
> **最終更新**: 2026-03-03
> **最終更新**: 2026-03-04
> **対象システム**: windmill.keinafarm.netワークスペース: admins
> **目的**: このドキュメントだけで Alexa TTS API の全容を把握し、作業を継続できること
> **関連ドキュメント**: `docs/31_Alexa_Cookie更新GUI運用.md`
---
## 目次
@@ -44,10 +46,11 @@ Windmill ワークフロー
### 現在の状態
**✅ 完全動作中2026-03-03 解決済み**
**✅ 完全動作中2026-03-04 時点**
- ローカルPCからもサーバーのDockerコンテナからも、日本語テキストの読み上げが動作する
- 解決の鍵: `sequenceJson` 内の日本語文字を `\uXXXX` 形式にエスケープして送信する
- 補足: `u/admin/alexa_speak` を API 更新した直後、Windmill UI の入力欄が即時更新されない場合がある(後述の運用回避策を適用)
---
@@ -83,6 +86,14 @@ Windmill ワークフロー
- 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. ファイル構成
@@ -95,7 +106,11 @@ Windmill ワークフロー
| `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 取得スクリプト | **ローカルのみで実行**Windowsブラウザ認証が必要 |
| `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 は一覧表示から除外。 |
@@ -400,17 +415,40 @@ sudo docker restart traefik
### B. Cookie 更新時のデプロイ(ビルド不要)
```bash
# 1. ローカルで auth4.js を実行して .env を更新
cd alexa-api
AMAZON_EMAIL="xxx" AMAZON_PASSWORD="xxx" node auth4.js
# 1. ローカルで GUI を起動
cd /home/akira/develop/windmill_workflow/alexa-api
npm run auth:web
# 2. .env をサーバーに転送
scp alexa-api/.env keinafarm-claude:/home/claude/alexa-api/.env
# 2. ブラウザで http://127.0.0.1:3678 を開き、Amazon の認証情報を入力
# 3. コンテナを再起動restart で OK。Traefik 再起動不要)
ssh keinafarm-claude 'sudo docker compose -f /home/claude/alexa-api/docker-compose.yml restart'
# 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 を再検出させる。
@@ -506,6 +544,22 @@ curl -X POST \
"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. 既知の問題・落とし穴
@@ -546,9 +600,11 @@ curl -X POST \
| ファイル | 説明 |
|---------|------|
| `docs/alexa-api/10_Alexa TTS API 実装記録 (2026-03-02).md` | Claude Code による実装記録(Triliumからコピー |
| `docs/alexa-api/11_色々やってダメだった.txt` | ChatGPT との試行錯誤チャットログ |
| `docs/alexa-api/12_ローカルで試したこと.md` | 日本語TTS問題の調査記録解決過程 |
| `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` | 本ドキュメント |
---
@@ -602,3 +658,4 @@ var rawSequenceJson = JSON.stringify(sequenceObj).replace(
| 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

@@ -1,36 +0,0 @@
{
"formatVersion": 2,
"appVersion": "0.63.7",
"files": [
{
"noteId": "IRnO9uub2Bwg",
"notePath": [
"IRnO9uub2Bwg"
],
"isClone": false,
"title": "Alexa TTS API 実装記録 (2026-03-02)",
"notePosition": 50,
"prefix": null,
"isExpanded": false,
"type": "text",
"mime": "text/html",
"format": "html",
"dataFileName": "Alexa TTS API 実装記録 (2026-03-02.html",
"noImport": false,
"attributes": [],
"attachments": []
},
{
"noImport": true,
"dataFileName": "navigation.html"
},
{
"noImport": true,
"dataFileName": "index.html"
},
{
"noImport": true,
"dataFileName": "style.css"
}
]
}

View File

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

View File

@@ -1,11 +0,0 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<frameset cols="25%,75%">
<frame name="navigation" src="navigation.html">
<frame name="detail" src="Alexa%20TTS%20API%20%E5%AE%9F%E8%A3%85%E8%A8%98%E9%8C%B2%20(2026-03-02.html">
</frameset>
</html>

View File

@@ -1,16 +0,0 @@
<html>
<head>
<meta charset="utf-8">
<link rel="stylesheet" href="style.css">
</head>
<body>
<ul>
<li><a href="Alexa%20TTS%20API%20%E5%AE%9F%E8%A3%85%E8%A8%98%E9%8C%B2%20(2026-03-02.html"
target="detail">Alexa TTS API 実装記録 (2026-03-02)</a>
</li>
</ul>
</body>
</html>

View File

@@ -1,551 +0,0 @@
/* !!!!!! TRILIUM CUSTOM CHANGES !!!!!! */
.printed-content .ck-widget__selection-handle, .printed-content .ck-widget__type-around { /* gets rid of triangles: https://github.com/zadam/trilium/issues/1129 */
display: none;
}
/*
* CKEditor 5 (v41.0.0) content styles.
* Generated on Fri, 26 Jan 2024 10:23:49 GMT.
* For more information, check out https://ckeditor.com/docs/ckeditor5/latest/installation/advanced/content-styles.html
*/
:root {
--ck-color-image-caption-background: hsl(0, 0%, 97%);
--ck-color-image-caption-text: hsl(0, 0%, 20%);
--ck-color-mention-background: hsla(341, 100%, 30%, 0.1);
--ck-color-mention-text: hsl(341, 100%, 30%);
--ck-color-selector-caption-background: hsl(0, 0%, 97%);
--ck-color-selector-caption-text: hsl(0, 0%, 20%);
--ck-highlight-marker-blue: hsl(201, 97%, 72%);
--ck-highlight-marker-green: hsl(120, 93%, 68%);
--ck-highlight-marker-pink: hsl(345, 96%, 73%);
--ck-highlight-marker-yellow: hsl(60, 97%, 73%);
--ck-highlight-pen-green: hsl(112, 100%, 27%);
--ck-highlight-pen-red: hsl(0, 85%, 49%);
--ck-image-style-spacing: 1.5em;
--ck-inline-image-style-spacing: calc(var(--ck-image-style-spacing) / 2);
--ck-todo-list-checkmark-size: 16px;
}
/* @ckeditor/ckeditor5-table/theme/tablecolumnresize.css */
.ck-content .table .ck-table-resized {
table-layout: fixed;
}
/* @ckeditor/ckeditor5-table/theme/tablecolumnresize.css */
.ck-content .table table {
overflow: hidden;
}
/* @ckeditor/ckeditor5-table/theme/tablecolumnresize.css */
.ck-content .table td,
.ck-content .table th {
overflow-wrap: break-word;
position: relative;
}
/* @ckeditor/ckeditor5-table/theme/table.css */
.ck-content .table {
margin: 0.9em auto;
display: table;
}
/* @ckeditor/ckeditor5-table/theme/table.css */
.ck-content .table table {
border-collapse: collapse;
border-spacing: 0;
width: 100%;
height: 100%;
border: 1px double hsl(0, 0%, 70%);
}
/* @ckeditor/ckeditor5-table/theme/table.css */
.ck-content .table table td,
.ck-content .table table th {
min-width: 2em;
padding: .4em;
border: 1px solid hsl(0, 0%, 75%);
}
/* @ckeditor/ckeditor5-table/theme/table.css */
.ck-content .table table th {
font-weight: bold;
background: hsla(0, 0%, 0%, 5%);
}
/* @ckeditor/ckeditor5-table/theme/table.css */
.ck-content[dir="rtl"] .table th {
text-align: right;
}
/* @ckeditor/ckeditor5-table/theme/table.css */
.ck-content[dir="ltr"] .table th {
text-align: left;
}
/* @ckeditor/ckeditor5-table/theme/tablecaption.css */
.ck-content .table > figcaption {
display: table-caption;
caption-side: top;
word-break: break-word;
text-align: center;
color: var(--ck-color-selector-caption-text);
background-color: var(--ck-color-selector-caption-background);
padding: .6em;
font-size: .75em;
outline-offset: -1px;
}
/* @ckeditor/ckeditor5-page-break/theme/pagebreak.css */
.ck-content .page-break {
position: relative;
clear: both;
padding: 5px 0;
display: flex;
align-items: center;
justify-content: center;
}
/* @ckeditor/ckeditor5-page-break/theme/pagebreak.css */
.ck-content .page-break::after {
content: '';
position: absolute;
border-bottom: 2px dashed hsl(0, 0%, 77%);
width: 100%;
}
/* @ckeditor/ckeditor5-page-break/theme/pagebreak.css */
.ck-content .page-break__label {
position: relative;
z-index: 1;
padding: .3em .6em;
display: block;
text-transform: uppercase;
border: 1px solid hsl(0, 0%, 77%);
border-radius: 2px;
font-family: Helvetica, Arial, Tahoma, Verdana, Sans-Serif;
font-size: 0.75em;
font-weight: bold;
color: hsl(0, 0%, 20%);
background: hsl(0, 0%, 100%);
box-shadow: 2px 2px 1px hsla(0, 0%, 0%, 0.15);
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
/* @ckeditor/ckeditor5-media-embed/theme/mediaembed.css */
.ck-content .media {
clear: both;
margin: 0.9em 0;
display: block;
min-width: 15em;
}
/* @ckeditor/ckeditor5-list/theme/todolist.css */
.ck-content .todo-list {
list-style: none;
}
/* @ckeditor/ckeditor5-list/theme/todolist.css */
.ck-content .todo-list li {
position: relative;
margin-bottom: 5px;
}
/* @ckeditor/ckeditor5-list/theme/todolist.css */
.ck-content .todo-list li .todo-list {
margin-top: 5px;
}
/* @ckeditor/ckeditor5-list/theme/todolist.css */
.ck-content .todo-list .todo-list__label > input {
-webkit-appearance: none;
display: inline-block;
position: relative;
width: var(--ck-todo-list-checkmark-size);
height: var(--ck-todo-list-checkmark-size);
vertical-align: middle;
border: 0;
left: -25px;
margin-right: -15px;
right: 0;
margin-left: 0;
}
/* @ckeditor/ckeditor5-list/theme/todolist.css */
.ck-content[dir=rtl] .todo-list .todo-list__label > input {
left: 0;
margin-right: 0;
right: -25px;
margin-left: -15px;
}
/* @ckeditor/ckeditor5-list/theme/todolist.css */
.ck-content .todo-list .todo-list__label > input::before {
display: block;
position: absolute;
box-sizing: border-box;
content: '';
width: 100%;
height: 100%;
border: 1px solid hsl(0, 0%, 20%);
border-radius: 2px;
transition: 250ms ease-in-out box-shadow;
}
/* @ckeditor/ckeditor5-list/theme/todolist.css */
.ck-content .todo-list .todo-list__label > input::after {
display: block;
position: absolute;
box-sizing: content-box;
pointer-events: none;
content: '';
left: calc( var(--ck-todo-list-checkmark-size) / 3 );
top: calc( var(--ck-todo-list-checkmark-size) / 5.3 );
width: calc( var(--ck-todo-list-checkmark-size) / 5.3 );
height: calc( var(--ck-todo-list-checkmark-size) / 2.6 );
border-style: solid;
border-color: transparent;
border-width: 0 calc( var(--ck-todo-list-checkmark-size) / 8 ) calc( var(--ck-todo-list-checkmark-size) / 8 ) 0;
transform: rotate(45deg);
}
/* @ckeditor/ckeditor5-list/theme/todolist.css */
.ck-content .todo-list .todo-list__label > input[checked]::before {
background: hsl(126, 64%, 41%);
border-color: hsl(126, 64%, 41%);
}
/* @ckeditor/ckeditor5-list/theme/todolist.css */
.ck-content .todo-list .todo-list__label > input[checked]::after {
border-color: hsl(0, 0%, 100%);
}
/* @ckeditor/ckeditor5-list/theme/todolist.css */
.ck-content .todo-list .todo-list__label .todo-list__label__description {
vertical-align: middle;
}
/* @ckeditor/ckeditor5-list/theme/todolist.css */
.ck-content .todo-list .todo-list__label.todo-list__label_without-description input[type=checkbox] {
position: absolute;
}
/* @ckeditor/ckeditor5-list/theme/todolist.css */
.ck-editor__editable.ck-content .todo-list .todo-list__label > input,
.ck-editor__editable.ck-content .todo-list .todo-list__label > span[contenteditable=false] > input {
cursor: pointer;
}
/* @ckeditor/ckeditor5-list/theme/todolist.css */
.ck-editor__editable.ck-content .todo-list .todo-list__label > input:hover::before, .ck-editor__editable.ck-content .todo-list .todo-list__label > span[contenteditable=false] > input:hover::before {
box-shadow: 0 0 0 5px hsla(0, 0%, 0%, 0.1);
}
/* @ckeditor/ckeditor5-list/theme/todolist.css */
.ck-editor__editable.ck-content .todo-list .todo-list__label > span[contenteditable=false] > input {
-webkit-appearance: none;
display: inline-block;
position: relative;
width: var(--ck-todo-list-checkmark-size);
height: var(--ck-todo-list-checkmark-size);
vertical-align: middle;
border: 0;
left: -25px;
margin-right: -15px;
right: 0;
margin-left: 0;
}
/* @ckeditor/ckeditor5-list/theme/todolist.css */
.ck-editor__editable.ck-content[dir=rtl] .todo-list .todo-list__label > span[contenteditable=false] > input {
left: 0;
margin-right: 0;
right: -25px;
margin-left: -15px;
}
/* @ckeditor/ckeditor5-list/theme/todolist.css */
.ck-editor__editable.ck-content .todo-list .todo-list__label > span[contenteditable=false] > input::before {
display: block;
position: absolute;
box-sizing: border-box;
content: '';
width: 100%;
height: 100%;
border: 1px solid hsl(0, 0%, 20%);
border-radius: 2px;
transition: 250ms ease-in-out box-shadow;
}
/* @ckeditor/ckeditor5-list/theme/todolist.css */
.ck-editor__editable.ck-content .todo-list .todo-list__label > span[contenteditable=false] > input::after {
display: block;
position: absolute;
box-sizing: content-box;
pointer-events: none;
content: '';
left: calc( var(--ck-todo-list-checkmark-size) / 3 );
top: calc( var(--ck-todo-list-checkmark-size) / 5.3 );
width: calc( var(--ck-todo-list-checkmark-size) / 5.3 );
height: calc( var(--ck-todo-list-checkmark-size) / 2.6 );
border-style: solid;
border-color: transparent;
border-width: 0 calc( var(--ck-todo-list-checkmark-size) / 8 ) calc( var(--ck-todo-list-checkmark-size) / 8 ) 0;
transform: rotate(45deg);
}
/* @ckeditor/ckeditor5-list/theme/todolist.css */
.ck-editor__editable.ck-content .todo-list .todo-list__label > span[contenteditable=false] > input[checked]::before {
background: hsl(126, 64%, 41%);
border-color: hsl(126, 64%, 41%);
}
/* @ckeditor/ckeditor5-list/theme/todolist.css */
.ck-editor__editable.ck-content .todo-list .todo-list__label > span[contenteditable=false] > input[checked]::after {
border-color: hsl(0, 0%, 100%);
}
/* @ckeditor/ckeditor5-list/theme/todolist.css */
.ck-editor__editable.ck-content .todo-list .todo-list__label.todo-list__label_without-description input[type=checkbox] {
position: absolute;
}
/* @ckeditor/ckeditor5-list/theme/list.css */
.ck-content ol {
list-style-type: decimal;
}
/* @ckeditor/ckeditor5-list/theme/list.css */
.ck-content ol ol {
list-style-type: lower-latin;
}
/* @ckeditor/ckeditor5-list/theme/list.css */
.ck-content ol ol ol {
list-style-type: lower-roman;
}
/* @ckeditor/ckeditor5-list/theme/list.css */
.ck-content ol ol ol ol {
list-style-type: upper-latin;
}
/* @ckeditor/ckeditor5-list/theme/list.css */
.ck-content ol ol ol ol ol {
list-style-type: upper-roman;
}
/* @ckeditor/ckeditor5-list/theme/list.css */
.ck-content ul {
list-style-type: disc;
}
/* @ckeditor/ckeditor5-list/theme/list.css */
.ck-content ul ul {
list-style-type: circle;
}
/* @ckeditor/ckeditor5-list/theme/list.css */
.ck-content ul ul ul {
list-style-type: square;
}
/* @ckeditor/ckeditor5-list/theme/list.css */
.ck-content ul ul ul ul {
list-style-type: square;
}
/* @ckeditor/ckeditor5-image/theme/image.css */
.ck-content .image {
display: table;
clear: both;
text-align: center;
margin: 0.9em auto;
min-width: 50px;
}
/* @ckeditor/ckeditor5-image/theme/image.css */
.ck-content .image img {
display: block;
margin: 0 auto;
max-width: 100%;
min-width: 100%;
height: auto;
}
/* @ckeditor/ckeditor5-image/theme/image.css */
.ck-content .image-inline {
/*
* Normally, the .image-inline would have "display: inline-block" and "img { width: 100% }" (to follow the wrapper while resizing).;
* Unfortunately, together with "srcset", it gets automatically stretched up to the width of the editing root.
* This strange behavior does not happen with inline-flex.
*/
display: inline-flex;
max-width: 100%;
align-items: flex-start;
}
/* @ckeditor/ckeditor5-image/theme/image.css */
.ck-content .image-inline picture {
display: flex;
}
/* @ckeditor/ckeditor5-image/theme/image.css */
.ck-content .image-inline picture,
.ck-content .image-inline img {
flex-grow: 1;
flex-shrink: 1;
max-width: 100%;
}
/* @ckeditor/ckeditor5-image/theme/imageresize.css */
.ck-content img.image_resized {
height: auto;
}
/* @ckeditor/ckeditor5-image/theme/imageresize.css */
.ck-content .image.image_resized {
max-width: 100%;
display: block;
box-sizing: border-box;
}
/* @ckeditor/ckeditor5-image/theme/imageresize.css */
.ck-content .image.image_resized img {
width: 100%;
}
/* @ckeditor/ckeditor5-image/theme/imageresize.css */
.ck-content .image.image_resized > figcaption {
display: block;
}
/* @ckeditor/ckeditor5-image/theme/imagecaption.css */
.ck-content .image > figcaption {
display: table-caption;
caption-side: bottom;
word-break: break-word;
color: var(--ck-color-image-caption-text);
background-color: var(--ck-color-image-caption-background);
padding: .6em;
font-size: .75em;
outline-offset: -1px;
}
/* @ckeditor/ckeditor5-image/theme/imagestyle.css */
.ck-content .image-style-block-align-left,
.ck-content .image-style-block-align-right {
max-width: calc(100% - var(--ck-image-style-spacing));
}
/* @ckeditor/ckeditor5-image/theme/imagestyle.css */
.ck-content .image-style-align-left,
.ck-content .image-style-align-right {
clear: none;
}
/* @ckeditor/ckeditor5-image/theme/imagestyle.css */
.ck-content .image-style-side {
float: right;
margin-left: var(--ck-image-style-spacing);
max-width: 50%;
}
/* @ckeditor/ckeditor5-image/theme/imagestyle.css */
.ck-content .image-style-align-left {
float: left;
margin-right: var(--ck-image-style-spacing);
}
/* @ckeditor/ckeditor5-image/theme/imagestyle.css */
.ck-content .image-style-align-center {
margin-left: auto;
margin-right: auto;
}
/* @ckeditor/ckeditor5-image/theme/imagestyle.css */
.ck-content .image-style-align-right {
float: right;
margin-left: var(--ck-image-style-spacing);
}
/* @ckeditor/ckeditor5-image/theme/imagestyle.css */
.ck-content .image-style-block-align-right {
margin-right: 0;
margin-left: auto;
}
/* @ckeditor/ckeditor5-image/theme/imagestyle.css */
.ck-content .image-style-block-align-left {
margin-left: 0;
margin-right: auto;
}
/* @ckeditor/ckeditor5-image/theme/imagestyle.css */
.ck-content p + .image-style-align-left,
.ck-content p + .image-style-align-right,
.ck-content p + .image-style-side {
margin-top: 0;
}
/* @ckeditor/ckeditor5-image/theme/imagestyle.css */
.ck-content .image-inline.image-style-align-left,
.ck-content .image-inline.image-style-align-right {
margin-top: var(--ck-inline-image-style-spacing);
margin-bottom: var(--ck-inline-image-style-spacing);
}
/* @ckeditor/ckeditor5-image/theme/imagestyle.css */
.ck-content .image-inline.image-style-align-left {
margin-right: var(--ck-inline-image-style-spacing);
}
/* @ckeditor/ckeditor5-image/theme/imagestyle.css */
.ck-content .image-inline.image-style-align-right {
margin-left: var(--ck-inline-image-style-spacing);
}
/* @ckeditor/ckeditor5-highlight/theme/highlight.css */
.ck-content .marker-yellow {
background-color: var(--ck-highlight-marker-yellow);
}
/* @ckeditor/ckeditor5-highlight/theme/highlight.css */
.ck-content .marker-green {
background-color: var(--ck-highlight-marker-green);
}
/* @ckeditor/ckeditor5-highlight/theme/highlight.css */
.ck-content .marker-pink {
background-color: var(--ck-highlight-marker-pink);
}
/* @ckeditor/ckeditor5-highlight/theme/highlight.css */
.ck-content .marker-blue {
background-color: var(--ck-highlight-marker-blue);
}
/* @ckeditor/ckeditor5-highlight/theme/highlight.css */
.ck-content .pen-red {
color: var(--ck-highlight-pen-red);
background-color: transparent;
}
/* @ckeditor/ckeditor5-highlight/theme/highlight.css */
.ck-content .pen-green {
color: var(--ck-highlight-pen-green);
background-color: transparent;
}
/* @ckeditor/ckeditor5-block-quote/theme/blockquote.css */
.ck-content blockquote {
overflow: hidden;
padding-right: 1.5em;
padding-left: 1.5em;
margin-left: 0;
margin-right: 0;
font-style: italic;
border-left: solid 5px hsl(0, 0%, 80%);
}
/* @ckeditor/ckeditor5-block-quote/theme/blockquote.css */
.ck-content[dir="rtl"] blockquote {
border-left: 0;
border-right: solid 5px hsl(0, 0%, 80%);
}
/* @ckeditor/ckeditor5-basic-styles/theme/code.css */
.ck-content code {
background-color: hsla(0, 0%, 78%, 0.3);
padding: .15em;
border-radius: 2px;
}
/* @ckeditor/ckeditor5-font/theme/fontsize.css */
.ck-content .text-tiny {
font-size: .7em;
}
/* @ckeditor/ckeditor5-font/theme/fontsize.css */
.ck-content .text-small {
font-size: .85em;
}
/* @ckeditor/ckeditor5-font/theme/fontsize.css */
.ck-content .text-big {
font-size: 1.4em;
}
/* @ckeditor/ckeditor5-font/theme/fontsize.css */
.ck-content .text-huge {
font-size: 1.8em;
}
/* @ckeditor/ckeditor5-mention/theme/mention.css */
.ck-content .mention {
background: var(--ck-color-mention-background);
color: var(--ck-color-mention-text);
}
/* @ckeditor/ckeditor5-horizontal-line/theme/horizontalline.css */
.ck-content hr {
margin: 15px 0;
height: 4px;
background: hsl(0, 0%, 87%);
border: 0;
}
/* @ckeditor/ckeditor5-code-block/theme/codeblock.css */
.ck-content pre {
padding: 1em;
color: hsl(0, 0%, 20.8%);
background: hsla(0, 0%, 78%, 0.3);
border: 1px solid hsl(0, 0%, 77%);
border-radius: 2px;
text-align: left;
direction: ltr;
tab-size: 4;
white-space: pre-wrap;
font-style: normal;
min-width: 200px;
}
/* @ckeditor/ckeditor5-code-block/theme/codeblock.css */
.ck-content pre code {
background: unset;
padding: 0;
border-radius: 0;
}
@media print {
/* @ckeditor/ckeditor5-page-break/theme/pagebreak.css */
.ck-content .page-break {
padding: 0;
}
/* @ckeditor/ckeditor5-page-break/theme/pagebreak.css */
.ck-content .page-break::after {
display: none;
}
}

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

@@ -42,9 +42,10 @@
1. ローカルリポジトリは、サーバー側Gitをリモートにしない
2. Windmillの実体変更は **Windmill REST API 経由のみ**
3. 作業開始時にAPIでサーバー状態を取り込み、サーバーが新しければローカルへ同期
4. ローカル変更はAPIでサーバーへ反映
5. サーバー側の Git Sync Workflow は定期記録用途として継続
6. ローカルGitコミットは手動またはAIで実施し、監査/履歴用途として扱う
4. **運用単位は「workflow packageflow + schedules」を基本**とする
5. ローカル変更はAPIでサーバーへ反映
6. サーバー側の Git Sync Workflow は定期記録用途として継続
7. ローカルGitコミットは手動またはAIで実施し、監査/履歴用途として扱う
---
@@ -78,6 +79,13 @@
- ローカルファイルは「編集用ワークツリー + 監査ログ」
- ローカルGitはサーバー同期の必須経路ではない
### 管理単位(重要)
- 単体オブジェクト運用scriptだけ、flowだけは補助用途
- 標準運用は **workflow package 単位** とする
- `flow` 本体
- その `flow path` に紐づく `schedules``is_flow=true` かつ `script_path == flow.path`
### 既存の主要オブジェクト(例)
- script: `u/admin/alexa_speak`
@@ -98,11 +106,18 @@
- サーバー `updated_at` / `hash` とローカル管理メタ情報を比較
- サーバーが新しければローカルを更新
### 対象API
### 対象API(オブジェクト単体)
- `GET /api/w/{workspace}/scripts/get/p/{path}`
- `GET /api/w/{workspace}/flows/get/{path}`
- `GET /api/w/{workspace}/schedules/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ローカル -> サーバー)
@@ -132,6 +147,14 @@
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. 競合時の動作仕様
@@ -148,6 +171,15 @@
- 削除再作成前に最新を必ず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` の対象に含める
---
@@ -157,7 +189,7 @@
- 本ドキュメント作成
## Phase 1: API運用コマンド整備次タスク
## Phase 1: API運用コマンド整備完了
`wm-api.sh` へ追加:
@@ -168,17 +200,26 @@
5. `pull-all`scripts/flowsの一覧取得 + 一括保存)
6. `status-remote`ローカルとサーバーのhash比較
## Phase 2: メタ情報管理
## Phase 2: workflow package 対応(次タスク)
- `state/remote_index.json` を導入し、`path -> hash/updated_at` を保持
- Pull前後で差分判定可能にする
`wm-api.sh` へ追加:
## Phase 3: 標準化
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 4: 半自動化(任意)
## Phase 5: 半自動化(任意)
- AI/スクリプトで
- 変更検知
@@ -193,14 +234,16 @@
1. `pull-all` でサーバー最新を取得
2. ローカル編集
3. `push-script` / `push-flow` で反映
4. 必要に応じてローカルGitにコミット
3. workflow修正時は `push-workflow` で反映flow + schedules
4. script単体修正時のみ `push-script` を使う
5. 必要に応じてローカルGitにコミット
### 8.2 サーバーで変更されたものを取り込む
1. `status-remote` 実行
2. サーバー新規/更新分を `pull-*` で取得
3. ローカル履歴としてコミット(任意)
2. workflow単位の変更は `status-workflow` / `pull-workflow` で取得
3. 単体変更は `pull-*` で取得
4. ローカル履歴としてコミット(任意)
### 8.3 `alexa_speak` 更新例(現在の具体例)
@@ -208,6 +251,11 @@
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`
---
@@ -228,7 +276,8 @@
1. Push前に対象オブジェクトをバックアップ保存必須
2. Push後に `post-verify`(再取得して期待値比較)を必須化
3. 失敗時は「再実行」ではなく「現状確認 -> 復旧」の順で実施
3. Push直前に `preflight hash check` を必須化し、不一致時はPush停止fail closed
4. 失敗時は「再実行」ではなく「現状確認 -> 復旧」の順で実施
### 10.2 標準復旧手順
@@ -246,6 +295,13 @@
2. 依存スケジュールの有効性を確認
3. 関連ジョブの手動実行で動作確認
### 10.4 delete -> create 運用ガード(必須)
1. `push-flow` / `push-workflow` 前に対象flowのバックアップJSONを必ず保存
2. Preflightで hash 不一致なら自動Pushしない手動レビューへフォールバック
3. Push後に flow と schedules を再取得し、ローカル期待値と一致確認
4. 不一致時は即時にバックアップから復元し、復旧ログを残す
---
## 11. Windmill依存を薄くする方針必須
@@ -256,7 +312,7 @@ Windmill API依存は避けられないため、依存点を最小化する。
- `scripts`: `list/get/create`
- `flows`: `list/get/create/delete`
- `schedules`: `list/get/create/delete`必要時
- `schedules`: `list/get/create/delete`workflow package 同期で常用
上記以外のAPIは原則使わない。
@@ -278,18 +334,20 @@ Windmill API依存は避けられないため、依存点を最小化する。
以下を満たせば「このプロジェクトはサーバーのワークフローを管理するためのもの」と言える状態:
1. ローカルから scripts/flows の Pull/Push がAPIで完結
2. サーバー更新がローカル検知できるhash比較
3. 競合時の復旧手順が定義済み
1. ローカルから workflow packageflow + schedulesの Pull/Push がAPIで完結
2. サーバー更新が workflow 単位でローカル検知できる
3. 競合時の復旧手順が flow/schedule 両方で定義済み
4. 運用手順がこの文書だけで再現可能
---
## 13. 既知の注意点
1. `wm-api.sh`現状 `bash` 前提だが、Windowsで改行がCRLFだと shebang 実行失敗する場合があ
1. `wm-api.sh``bash` 前提WindowsCRLF混入で shebang 実行失敗し得るため、`.gitattributes``*.sh text eol=lf` を固定し、実行は `bash wm-api.sh ...` を標準とする
2. フロー更新は環境により `PUT` できないため削除再作成を標準とする
3. API応答仕様はWindmillバージョン差で微差が出るため、初回導入時は `get` のレスポンス形を確認す
3. 削除再作成は `version_id/hash/edited_at` を更新するため、Preflight hash check がないと競合上書きを見落とす可能性があ
4. 1つのflowに複数scheduleが紐づくことがあるため、`script_path` ベースで束ねて管理する
5. API応答仕様はWindmillバージョン差で微差が出るため、初回導入時は `get` のレスポンス形を確認する
---
@@ -299,3 +357,6 @@ Windmill API依存は避けられないため、依存点を最小化する。
|------|----------|
| 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": [
{
"id": "a",
"summary": "変更確認・LINE通知",
"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",
"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",
"input_transforms": {},
"lock": ""
}
"language": "python3",
"input_transforms": {}
},
"summary": "変更確認・LINE通知"
}
]
},

View File

@@ -6,70 +6,70 @@
"modules": [
{
"id": "a",
"summary": "Step1: 診断データ生成",
"value": {
"lock": "# py: 3.12\n",
"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",
"input_transforms": {},
"lock": ""
}
"language": "python3",
"input_transforms": {}
},
"summary": "Step1: 診断データ生成"
},
{
"id": "b",
"summary": "Step2: データ検証",
"value": {
"lock": "# py: 3.12\n",
"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",
"language": "python3",
"input_transforms": {
"step1_result": {
"type": "javascript",
"expr": "results.a"
"expr": "results.a",
"type": "javascript"
}
}
},
"lock": ""
}
"summary": "Step2: データ検証"
},
{
"id": "c",
"summary": "Step3: HTTPヘルスチェック",
"value": {
"lock": "# py: 3.12\n",
"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",
"language": "python3",
"input_transforms": {
"verification_result": {
"type": "javascript",
"expr": "results.b"
"expr": "results.b",
"type": "javascript"
}
}
},
"lock": ""
}
"summary": "Step3: HTTPヘルスチェック"
},
{
"id": "d",
"summary": "Step4: 年度判定 & 最終レポート",
"value": {
"lock": "# py: 3.12\n",
"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",
"language": "python3",
"input_transforms": {
"http_check": {
"expr": "results.c",
"type": "javascript"
},
"step1_data": {
"type": "javascript",
"expr": "results.a"
"expr": "results.a",
"type": "javascript"
},
"verification": {
"type": "javascript",
"expr": "results.b"
},
"http_check": {
"type": "javascript",
"expr": "results.c"
"expr": "results.b",
"type": "javascript"
}
}
},
"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,69 +1,20 @@
/**
* alexa_speak.ts
* 指定した Echo デバイスにテキストを読み上げさせる Windmill スクリプト
*
* パラメータ:
* device - ドロップダウンから選択するデバイス(内部的にはシリアル番号)
* text - 読み上げるテキスト
*/
const ALEXA_API_URL = "http://alexa_api:3500";
type DeviceOption = { value: string; label: string };
const FALLBACK_DEVICE_OPTIONS: DeviceOption[] = [
{ value: "G0922H085165007R", label: "プレハブ (G0922H085165007R)" },
{ value: "G8M2DB08522600RL", label: "リビングエコー1 (G8M2DB08522600RL)" },
{ value: "G8M2DB08522503WF", label: "リビングエコー2 (G8M2DB08522503WF)" },
{ value: "G0922H08525302K5", label: "オフィスの右エコー (G0922H08525302K5)" },
{ value: "G0922H08525302J9", label: "オフィスの左エコー (G0922H08525302J9)" },
{ value: "G8M2HN08534302XH", label: "寝室のエコー (G8M2HN08534302XH)" },
];
// Windmill Dynamic Select: 引数名 `device` に対応する `DynSelect_device` と `device()` を定義
export type DynSelect_device = string;
export async function device(): Promise<DeviceOption[]> {
try {
const res = await fetch(`${ALEXA_API_URL}/devices`);
if (!res.ok) return FALLBACK_DEVICE_OPTIONS;
const devices = (await res.json()) as Array<{
name?: string;
serial?: string;
family?: string;
}>;
const options = devices
.filter((d) => d.family === "ECHO" && d.serial)
.map((d) => ({
value: d.serial as string,
label: `${d.name ?? d.serial} (${d.serial})`,
}))
.sort((a, b) => a.label.localeCompare(b.label, "ja"));
return options.length > 0 ? options : FALLBACK_DEVICE_OPTIONS;
} catch {
return FALLBACK_DEVICE_OPTIONS;
}
}
export async function main(
device: DynSelect_device,
device: string,
text: string,
): Promise<{ ok: boolean; device: string; text: string }> {
const ALEXA_API_URL = "http://alexa_api:3500";
const res = await fetch(`${ALEXA_API_URL}/speak`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ device, text }),
body: JSON.stringify({ device, text }), // ← SSMLなし、素のテキスト
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(
`alexa-api error ${res.status}: ${JSON.stringify(body)}`
);
throw new Error(`alexa-api error ${res.status}: ${JSON.stringify(body)}`);
}
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