フォルダを監視し画像を自動でリサイズするWeb-UIをセルフホスト

個人的にあると便利なので作ってみました。指定フォルダを監視して自動リサイズするだけのツールです。
Web-UIで設定を変更出来ます。

LXDコンテナにインストール

LXDコンテナにインストールします。Tailscale ServeでHTTPS化しています。
インストール時に監視フォルダや画像サイズを指定しますが、これは起動後にWeb-UIで変更可能です。

#!/usr/bin/env bash
# =============================================================================
#  Image Resize Service — インストールスクリプト
#  - 特定フォルダを監視し、画像(jpg/jpeg/png)をリサイズしてresizeサブフォルダに保存
#  - Web-UIで監視フォルダ・リサイズサイズを設定可能
#  - nextExplorer等のアップロード(.uploading一時ファイル→リネーム)にも対応
# =============================================================================
set -euo pipefail

INSTALL_DIR="/opt/image-resize"
SERVICE_NAME="image-resize"
PORT=3324

info()  { echo -e "\033[1;34m[INFO]\033[0m  $*"; }
ok()    { echo -e "\033[1;32m[ OK ]\033[0m  $*"; }
warn()  { echo -e "\033[1;33m[WARN]\033[0m  $*"; }
die()   { echo -e "\033[1;31m[ERR ]\033[0m  $*" >&2; exit 1; }

# ── 前提チェック ───────────────────────────────────────────────────────────────
info "前提確認..."
command -v python3   >/dev/null 2>&1 || die "python3 が見つかりません"

TS_AVAILABLE=false
if command -v tailscale >/dev/null 2>&1 && tailscale status >/dev/null 2>&1; then
  TS_AVAILABLE=true
  TS_HOSTNAME=$(tailscale status --json \
    | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['Self']['DNSName'].rstrip('.'))" \
    2>/dev/null) || TS_AVAILABLE=false
fi
ok "前提 OK"

# ── 監視フォルダの入力 ────────────────────────────────────────────────────────
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "  監視するフォルダを指定してください。"
echo "  (空 Enter でデフォルト: /opt/lxd-data/images)"
echo "  存在しない場合は自動で作成します。"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
read -rp "  監視フォルダ: " WATCH_FOLDER
WATCH_FOLDER="${WATCH_FOLDER:-/opt/lxd-data/images}"
if [[ ! -d "${WATCH_FOLDER}" ]]; then
  info "フォルダが存在しないため自動作成します: ${WATCH_FOLDER}"
fi
mkdir -p "${WATCH_FOLDER}"
ok "監視フォルダ: ${WATCH_FOLDER}"

# ── リサイズサイズの入力 ─────────────────────────────────────────────────────
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "  リサイズ後の最大サイズを指定してください。"
echo "  (空 Enter でデフォルト: 幅1024 x 高さ1024)"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
read -rp "  最大幅 (px) [1024]: " RESIZE_W
RESIZE_W="${RESIZE_W:-1024}"
read -rp "  最大高さ (px) [1024]: " RESIZE_H
RESIZE_H="${RESIZE_H:-1024}"
ok "リサイズ: ${RESIZE_W} x ${RESIZE_H}"

# ── ポート番号の入力 ─────────────────────────────────────────────────────────
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "  Web-UI のポート番号を指定してください。"
echo "  (空 Enter でデフォルト: 3324)"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
read -rp "  ポート [3324]: " INPUT_PORT
PORT="${INPUT_PORT:-3324}"
ok "ポート: ${PORT}"

# ── ディレクトリ作成 ──────────────────────────────────────────────────────────
info "ディレクトリ作成..."
mkdir -p "${INSTALL_DIR}"

# ── venv 作成 & 依存パッケージインストール ────────────────────────────────────
info "Python venv を作成..."

# 既存venvが壊れている場合は削除
if [[ -d "${INSTALL_DIR}/venv" ]]; then
  if ! "${INSTALL_DIR}/venv/bin/python" -c "import sys; print(sys.version)" >/dev/null 2>&1; then
    warn "既存のvenvが壊れています。削除して再作成します..."
    rm -rf "${INSTALL_DIR}/venv"
  fi
fi

# venv作成テスト
VENV_OK=false
if [[ ! -d "${INSTALL_DIR}/venv" ]]; then
  # まずテストでvenv作成を試みる
  TMPDIR_VENV=$(mktemp -d)
  if python3 -m venv "${TMPDIR_VENV}/test" 2>/dev/null && "${TMPDIR_VENV}/test/bin/python" -c "import sys; print(sys.version)" >/dev/null 2>&1; then
    VENV_OK=true
    rm -rf "${TMPDIR_VENV}"
  else
    rm -rf "${TMPDIR_VENV}"
  fi
fi

# venvがまだ無い場合、必要に応じてパッケージインストール
if [[ ! -d "${INSTALL_DIR}/venv" ]]; then
  if [[ "${VENV_OK}" == "false" ]]; then
    warn "venv が利用できません。python3-venv をインストールします..."
    PYVER=$(python3 -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')" 2>/dev/null)
    # sudo確認
    SUDO_CMD=""
    if [[ $EUID -ne 0 ]]; then
      SUDO_CMD="sudo"
    fi
    ${SUDO_CMD} apt-get update -qq 2>/dev/null
    ${SUDO_CMD} apt-get install -y -qq "python3.${PYVER}-venv" 2>/dev/null || \
    ${SUDO_CMD} apt-get install -y -qq python3-venv 2>/dev/null || \
    die "python3-venv のインストールに失敗しました。手動でインストールしてください: sudo apt install python3-venv"
    # インストール後、再度テスト
    TMPDIR_VENV=$(mktemp -d)
    if python3 -m venv "${TMPDIR_VENV}/test" 2>/dev/null && "${TMPDIR_VENV}/test/bin/python" -c "import sys; print(sys.version)" >/dev/null 2>&1; then
      VENV_OK=true
    fi
    rm -rf "${TMPDIR_VENV}"
  fi

  if [[ "${VENV_OK}" == "true" ]]; then
    python3 -m venv "${INSTALL_DIR}/venv" || die "venv の作成に失敗しました"
  else
    die "venv の作成に失敗しました。手動でインストールしてください: sudo apt install python3-venv"
  fi
fi

# 最終確認
if ! "${INSTALL_DIR}/venv/bin/python" -c "import sys; print(sys.version)" >/dev/null 2>&1; then
  die "venv の作成に失敗しました。手動でインストールしてください: sudo apt install python3-venv"
fi
ok "venv 作成完了 ($("${INSTALL_DIR}/venv/bin/python" -c "import sys; print(sys.version)"))"
info "依存パッケージをインストール..."
"${INSTALL_DIR}/venv/bin/pip" install --quiet --upgrade Pillow watchdog flask
ok "依存パッケージインストール完了"

# ── アプリケーションスクリプト生成 ────────────────────────────────────────────
info "アプリケーションを生成..."
cat > "${INSTALL_DIR}/app.py" << 'PYEOF'
#!/usr/bin/env python3
"""Image Resize Service — Watch a folder and resize images to a subfolder."""

import json
import os
import sys
import time
import threading
from pathlib import Path

from flask import Flask, render_template_string, request, jsonify
from PIL import Image
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler

CONFIG_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.json")

DEFAULT_CONFIG = {
    "watch_folder": "/opt/lxd-data/images",
    "resize_width": 1024,
    "resize_height": 1024,
    "keep_aspect_ratio": True,
    "output_suffix": "_resized",
    "extensions": [".jpg", ".jpeg", ".png"],
    "enabled": True,
    "port": 3324,
}

app = Flask(__name__)
config = {}
observer = None


def load_config():
    global config
    if os.path.exists(CONFIG_FILE):
        with open(CONFIG_FILE, "r") as f:
            config = json.load(f)
    else:
        config = DEFAULT_CONFIG.copy()
        save_config()


def save_config():
    with open(CONFIG_FILE, "w") as f:
        json.dump(config, f, indent=2, ensure_ascii=False)


def resize_image(src_path, dst_path, width, height, keep_aspect):
    try:
        img = Image.open(src_path)
        if keep_aspect:
            img.thumbnail((width, height), Image.LANCZOS)
        else:
            img = img.resize((width, height), Image.LANCZOS)
        os.makedirs(os.path.dirname(dst_path), exist_ok=True)
        img.save(dst_path, quality=90)
        return True
    except Exception as e:
        print(f"[ERROR] Failed to resize {src_path}: {e}", file=sys.stderr)
        return False


def get_output_path(src_path):
    p = Path(src_path)
    resize_dir = p.parent / "resize"
    ext = p.suffix.lower()
    stem = p.stem + config.get("output_suffix", "_resized")
    return str(resize_dir / (stem + ext))


class ImageHandler(FileSystemEventHandler):
    def __init__(self):
        self._pending = {}

    def on_created(self, event):
        if event.is_directory:
            return
        print(f"[DEBUG] File created: {event.src_path}", flush=True)
        self._handle(event.src_path)

    def on_modified(self, event):
        if event.is_directory:
            return
        print(f"[DEBUG] File modified: {event.src_path}", flush=True)
        self._handle(event.src_path)

    def on_moved(self, event):
        if event.is_directory:
            return
        print(f"[DEBUG] File moved: {event.src_path} -> {event.dest_path}", flush=True)
        self._handle(event.dest_path)

    def _handle(self, path):
        ext = Path(path).suffix.lower()
        if ext not in config.get("extensions", [".jpg", ".jpeg", ".png"]):
            return
        if "resize" in Path(path).parts:
            return
        self._pending[path] = time.time()
        threading.Thread(target=self._delayed_process, args=(path,), daemon=True).start()

    def _delayed_process(self, path):
        time.sleep(0.5)
        if path in self._pending:
            del self._pending[path]
        if not os.path.exists(path):
            return
        dst = get_output_path(path)
        ok = resize_image(
            path, dst,
            config.get("resize_width", 1024),
            config.get("resize_height", 1024),
            config.get("keep_aspect_ratio", True),
        )
        if ok:
            print(f"[OK] Resized: {os.path.basename(path)} -> {dst}", flush=True)


def start_watching():
    global observer
    if observer and observer.is_alive():
        print(f"[INFO] Stopping observer on: {observer._handlers}", flush=True)
        observer.stop()
        observer.join(timeout=5)
        observer = None
    if not config.get("enabled", True):
        print("[INFO] Watching disabled", flush=True)
        return
    watch_dir = config.get("watch_folder", "")
    if not watch_dir or not os.path.isdir(watch_dir):
        print(f"[WARN] Watch folder not found: {watch_dir}", flush=True)
        return
    observer = Observer()
    observer.schedule(ImageHandler(), watch_dir, recursive=False)
    observer.start()
    print(f"[INFO] Watching: {watch_dir}", flush=True)


HTML_TEMPLATE = """<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Image Resize Service</title>
<style>
  * { box-sizing: border-box; margin: 0; padding: 0; }
  body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f0f2f5; color: #333; }
  .header { background: #1a73e8; color: white; padding: 16px 24px; display: flex; align-items: center; gap: 12px; }
  .header h1 { font-size: 20px; }
  .status { margin-left: auto; padding: 4px 12px; border-radius: 12px; font-size: 13px; font-weight: 600; }
  .status.on { background: #34a853; }
  .status.off { background: #ea4335; }
  .container { max-width: 720px; margin: 24px auto; padding: 0 16px; }
  .card { background: white; border-radius: 12px; box-shadow: 0 1px 3px rgba(0,0,0,0.12); padding: 24px; margin-bottom: 16px; }
  .card h2 { font-size: 16px; margin-bottom: 16px; color: #555; }
  .field { margin-bottom: 16px; }
  .field label { display: block; font-size: 13px; font-weight: 600; margin-bottom: 4px; color: #666; }
  .field input[type="text"], .field input[type="number"] {
    width: 100%; padding: 10px 12px; border: 1px solid #ddd; border-radius: 8px; font-size: 15px;
  }
  .field input:focus { outline: none; border-color: #1a73e8; }
  .row { display: flex; gap: 16px; }
  .row .field { flex: 1; }
  .toggle { display: flex; align-items: center; gap: 8px; margin-bottom: 16px; }
  .toggle input[type="checkbox"] { width: 20px; height: 20px; }
  .btn { display: inline-block; padding: 10px 24px; border: none; border-radius: 8px; font-size: 15px; font-weight: 600; cursor: pointer; }
  .btn-primary { background: #1a73e8; color: white; }
  .btn-primary:hover { background: #1557b0; }
  .log { background: #1e1e1e; color: #d4d4d4; border-radius: 8px; padding: 12px; font-family: 'Courier New', monospace; font-size: 13px; max-height: 300px; overflow-y: auto; white-space: pre-wrap; }
  .log .ok { color: #4ec9b0; }
  .log .err { color: #f44747; }
  .log .info { color: #569cd6; }
</style>
</head>
<body>
<div class="header">
  <h1>Image Resize Service</h1>
  <span class="status {{ 'on' if config.enabled else 'off' }}" id="statusBadge">
    {{ '監視中' if config.enabled else '停止中' }}
  </span>
</div>
<div class="container">
  <div class="card">
    <h2>設定</h2>
    <div class="field">
      <label>監視フォルダ</label>
      <input type="text" id="watchFolder" value="{{ config.watch_folder }}">
    </div>
    <div class="row">
      <div class="field">
        <label>リサイズ幅 (px)</label>
        <input type="number" id="resizeWidth" value="{{ config.resize_width }}" min="1">
      </div>
      <div class="field">
        <label>リサイズ高さ (px)</label>
        <input type="number" id="resizeHeight" value="{{ config.resize_height }}" min="1">
      </div>
    </div>
    <div class="toggle">
      <input type="checkbox" id="keepAspect" {{ 'checked' if config.keep_aspect_ratio }}>
      <label for="keepAspect">アスペクト比を保持</label>
    </div>
    <div class="field">
      <label>出力サフィックス</label>
      <input type="text" id="outputSuffix" value="{{ config.output_suffix }}">
    </div>
    <div class="toggle">
      <input type="checkbox" id="enabled" {{ 'checked' if config.enabled }}>
      <label for="enabled">監視を有効にする</label>
    </div>
    <button class="btn btn-primary" onclick="saveConfig()">保存して適用</button>
  </div>

  <div class="card">
    <h2>現在のフォルダ内容</h2>
    <div id="folderContents" class="log">読み込み中...</div>
    <div style="margin-top:12px">
      <button class="btn btn-primary" onclick="refreshFolder()">更新</button>
      <button class="btn btn-primary" onclick="addTestImage()">テスト画像を追加</button>
    </div>
  </div>
</div>

<script>
function refreshFolder() {
  fetch('/api/folder').then(r => r.json()).then(data => {
    const el = document.getElementById('folderContents');
    el.innerHTML = '';
    if (data.items.length === 0) { el.textContent = '(空)'; return; }
    data.items.forEach(item => {
      const line = document.createElement('span');
      line.className = item.type === 'resize' ? 'ok' : 'info';
      line.textContent = `[${item.type}] ${item.name} (${item.size})\\n`;
      el.appendChild(line);
    });
  });
}

function saveConfig() {
  const folder = document.getElementById('watchFolder').value;
  const cfg = {
    watch_folder: folder,
    resize_width: parseInt(document.getElementById('resizeWidth').value),
    resize_height: parseInt(document.getElementById('resizeHeight').value),
    keep_aspect_ratio: document.getElementById('keepAspect').checked,
    output_suffix: document.getElementById('outputSuffix').value,
    enabled: document.getElementById('enabled').checked,
  };
  fetch('/api/check-folder', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({path: folder}),
  }).then(r => r.json()).then(data => {
    if (!data.exists) {
      if (!confirm('フォルダ「' + folder + '」が存在しません。\\n作成しますか?')) {
        return;
      }
      cfg.create_folder = true;
    }
    return fetch('/api/config', {
      method: 'POST',
      headers: {'Content-Type': 'application/json'},
      body: JSON.stringify(cfg),
    });
  }).then(r => r && r.json()).then(() => {
    document.getElementById('statusBadge').className = 'status ' + (cfg.enabled ? 'on' : 'off');
    document.getElementById('statusBadge').textContent = cfg.enabled ? '監視中' : '停止中';
    refreshFolder();
  });
}

function addTestImage() {
  fetch('/api/test-image', {method: 'POST'}).then(r => r.json()).then(() => {
    setTimeout(refreshFolder, 1000);
  });
}

refreshFolder();
</script>
</body>
</html>"""


@app.route("/")
def index():
    return render_template_string(HTML_TEMPLATE, config=config)


@app.route("/api/config", methods=["GET", "POST"])
def api_config():
    global config
    if request.method == "POST":
        data = request.json
        create_folder = data.pop("create_folder", False)
        if create_folder:
            watch_dir = data.get("watch_folder", "")
            if watch_dir:
                os.makedirs(watch_dir, exist_ok=True)
                print(f"[INFO] Created folder: {watch_dir}", flush=True)
        config.update(data)
        save_config()
        start_watching()
        return jsonify({"ok": True})
    return jsonify(config)


@app.route("/api/check-folder", methods=["POST"])
def api_check_folder():
    path = request.json.get("path", "")
    exists = os.path.isdir(path) if path else False
    return jsonify({"exists": exists, "path": path})


@app.route("/api/folder")
def api_folder():
    items = []
    watch = config.get("watch_folder", "")
    if os.path.isdir(watch):
        for f in sorted(os.listdir(watch)):
            fp = os.path.join(watch, f)
            if os.path.isdir(fp):
                if f == "resize":
                    for rf in sorted(os.listdir(fp)):
                        rfp = os.path.join(fp, rf)
                        if os.path.isfile(rfp):
                            sz = os.path.getsize(rfp)
                            items.append({"name": f"resize/{rf}", "size": f"{sz//1024}KB", "type": "resize"})
            else:
                ext = Path(f).suffix.lower()
                if ext in config.get("extensions", []):
                    sz = os.path.getsize(fp)
                    items.append({"name": f, "size": f"{sz//1024}KB", "type": "image"})
    return jsonify({"items": items})


@app.route("/api/test-image", methods=["POST"])
def api_test_image():
    import random
    watch = config.get("watch_folder", "")
    os.makedirs(watch, exist_ok=True)
    w, h = random.randint(1600, 4000), random.randint(1200, 3000)
    img = Image.new("RGB", (w, h), (
        random.randint(0, 255), random.randint(0, 255), random.randint(0, 255)
    ))
    from PIL import ImageDraw
    draw = ImageDraw.Draw(img)
    for _ in range(20):
        x1, y1 = random.randint(0, w), random.randint(0, h)
        x2, y2 = x1 + random.randint(50, 300), y1 + random.randint(50, 300)
        draw.rectangle([x1, y1, x2, y2], fill=(
            random.randint(0, 255), random.randint(0, 255), random.randint(0, 255)
        ))
    ts = int(time.time() * 1000)
    path = os.path.join(watch, f"test_{ts}.jpg")
    img.save(path, quality=95)
    return jsonify({"ok": True, "path": path})


def main():
    load_config()
    watch_dir = config.get("watch_folder", "/tmp")
    if not os.path.isdir(watch_dir):
        print(f"[INFO] Creating watch folder: {watch_dir}", flush=True)
    os.makedirs(watch_dir, exist_ok=True)
    start_watching()
    port = config.get("port", 3324)
    print(f"[INFO] Web UI: http://127.0.0.1:{port}", flush=True)
    app.run(host="0.0.0.0", port=port, debug=False)


if __name__ == "__main__":
    main()
PYEOF
ok "アプリケーションスクリプト生成完了"

# ── config.json 生成 ──────────────────────────────────────────────────────────
cat > "${INSTALL_DIR}/config.json" << CFGEOF
{
  "watch_folder": "${WATCH_FOLDER}",
  "resize_width": ${RESIZE_W},
  "resize_height": ${RESIZE_H},
  "keep_aspect_ratio": true,
  "output_suffix": "_resized",
  "extensions": [".jpg", ".jpeg", ".png"],
  "enabled": true,
  "port": ${PORT}
}
CFGEOF
ok "config.json 生成完了"

# ── systemd ユニットファイル生成 ───────────────────────────────────────────────
info "systemd ユニットファイルを生成..."
cat > /etc/systemd/system/${SERVICE_NAME}.service << EOF
[Unit]
Description=Image Resize Service
After=network.target

[Service]
Type=simple
ExecStart=${INSTALL_DIR}/venv/bin/python ${INSTALL_DIR}/app.py
WorkingDirectory=${INSTALL_DIR}
Restart=on-failure
RestartSec=5

[Install]
WantedBy=multi-user.target
EOF
ok "systemd ユニットファイル生成完了"

# ── サービス起動(Tailscale Serve の前に起動) ────────────────────────────────
info "サービスを起動..."
systemctl daemon-reload
systemctl enable "${SERVICE_NAME}"
systemctl restart "${SERVICE_NAME}"
# サービスが起動するまで待機(最大10秒)
for i in $(seq 1 10); do
  if curl -s --max-time 1 "http://127.0.0.1:${PORT}/" >/dev/null 2>&1; then
    break
  fi
  sleep 1
done
if curl -s --max-time 1 "http://127.0.0.1:${PORT}/" >/dev/null 2>&1; then
  ok "サービス起動完了"
else
  die "サービスの起動に失敗しました。journalctl -u ${SERVICE_NAME} -n 20 で確認してください"
fi

# ── Tailscale Serve 設定(サービス起動後に設定) ──────────────────────────────
if [[ "${TS_AVAILABLE}" == "true" ]]; then
  info "Tailscale Serve にポート ${PORT} を追加..."
  # 既存の同ポート設定を削除
  tailscale serve --https="${PORT}" off 2>/dev/null || true
  tailscale serve --bg --https="${PORT}" "http://127.0.0.1:${PORT}"
  ok "Tailscale Serve 設定追加完了"
else
  warn "Tailscale が利用できないため、Tailscale Serve 設定をスキップします。"
  warn "Web-UI は http://127.0.0.1:${PORT} でアクセスできます。"
fi

# ── 完了サマリー ──────────────────────────────────────────────────────────────
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
ok "セットアップ完了!"
echo ""
if [[ "${TS_AVAILABLE}" == "true" ]]; then
  echo "  Web UI    : https://${TS_HOSTNAME}:${PORT}"
else
  echo "  Web UI    : http://127.0.0.1:${PORT}"
fi
echo "  設定ファイル: ${INSTALL_DIR}/config.json"
echo "  監視フォルダ: ${WATCH_FOLDER}"
echo "  リサイズサイズ: ${RESIZE_W} x ${RESIZE_H}"
echo ""
echo "  管理コマンド:"
echo "    systemctl status ${SERVICE_NAME}"
echo "    systemctl restart ${SERVICE_NAME}"
echo "    journalctl -u ${SERVICE_NAME} -f"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"

指定フォルダに保存した画像が自動リサイズ

監視フォルダに画像を入れれば設定したサイズに自動でリサイズされます。
これはnextExplorerで確認したところ。これでアップロードした画像もリサイズされます。

タイトルとURLをコピーしました