🚧
[GAS][LINE]道路通報アプリ
本記事の分類
- 学習ノート
機能
- LINE公式アカウントのリッチメニューから投稿ページを開く
- 道路の不具合情報(内容、写真、位置)を投稿する
- 道路の不具合情報をGoogleSpreadSheetに保存する
- 投稿者のラインアカウントへ投稿内容を送信する
- 登録メールアドレスへ投稿内容を送信する
想定シーン
- 道路の穴ボコ等をみつけた人が土木事務所にラインから通報する
仕様
システム仕様は下図のとおり
スマホ画面の仕様は下図のとおり
GoogleSpreadSheetの仕様は下図のとおり
code(frontend)
- editorは何でも良いです。作成ファイルはindex.html、script.js、style.css。(下図には余計なファイルも表示されています。)
index.html
- 以下のとおり
createStaffTable
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>道路の不具合通報フォーム</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="" />
<link rel="stylesheet" href="style.css" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<!-- LIFF SDK追加 -->
<script defer charset="utf-8" src="https://static.line-scdn.net/liff/edge/2/sdk.js"></script>
<script defer src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
<script defer src="script.js"></script>
</head>
<body>
<!-- ローディング画面 -->
<div id="loader" class="loader-overlay hidden">
<div class="loader"></div>
<p id="loader-text" class="loader-text">処理中です...</p>
</div>
<!-- メインコンテンツ -->
<div class="container">
<form id="report-form" novalidate>
<h2>道路の不具合を通報</h2>
<!-- LINE連携状態表示 -->
<div id="line-status" class="line-status hidden">
<div class="line-status-content">
<i class="fab fa-line line-icon"></i>
<span id="line-status-text">LINE連携を確認中...</span>
</div>
</div>
<div class="form-group">
<label class="form-label">1. 通報内容</label>
<div class="radio-group">
<div class="radio-item">
<input type="radio" id="type1" name="type" value="雑草" required>
<label for="type1">雑草</label>
</div>
<div class="radio-item">
<input type="radio" id="type2" name="type" value="倒木">
<label for="type2">倒木</label>
</div>
<div class="radio-item">
<input type="radio" id="type3" name="type" value="路面の穴ぼこ・段差">
<label for="type3">路面の穴ぼこ・段差</label>
</div>
<div class="radio-item">
<input type="radio" id="type4" name="type" value="落下物・汚れ">
<label for="type4">落下物・汚れ</label>
</div>
<div class="radio-item">
<input type="radio" id="type-other" name="type" value="その他">
<label for="type-other">その他</label>
</div>
</div>
</div>
<div class="form-group">
<label for="details" class="form-label">
詳細(100文字まで) <span class="label-note" id="details-required-note"></span>
</label>
<textarea id="details" name="details" rows="4" placeholder="例:直径20cm程度の穴ぼこ" maxlength="100"></textarea>
</div>
<div class="form-group">
<label for="photo">3. 現場の写真 (任意)</label>
<div class="photo-controls">
<label for="photo" class="button-like-input">
<i class="fas fa-file-image"></i> ファイルを選択
</label>
<input type="file" id="photo" name="photo" accept="image/*" style="display: none;">
</div>
<img id="image-preview" src="#" alt="写真プレビュー"/>
</div>
<div class="form-group">
<label>4. 現場の場所</label>
<div id="map-wrapper">
<div id="map"></div>
<i id="center-pin" class="fas fa-map-marker-alt"></i>
</div>
<div id="coords-display">地図を動かして位置を合わせてください</div>
<input type="hidden" id="latitude" name="latitude" required>
<input type="hidden" id="longitude" name="longitude" required>
<!-- LINEアクセストークン用の隠しフィールド -->
<input type="hidden" id="accessToken" name="accessToken">
<input type="hidden" id="userId" name="userId">
</div>
<button type="submit" id="btn-submit" disabled>不具合の種類を選択してください</button>
</form>
</div>
</body>
</html>
script.js
- 以下のとおり
createStaffTable
// script.js - LINE Login channel対応版
// ▼▼▼【重要】設定値を更新してください ▼▼▼
const APP_SETTINGS = {
MAX_RETRY_ATTEMPTS: 3,
RETRY_DELAY: 1000,
REQUEST_TIMEOUT: 30000,
MAX_FILE_SIZE: 5 * 1024 * 1024,
ALLOWED_FILE_TYPES: ['image/jpeg', 'image/png', 'image/gif', 'image/webp'],
DETAILS_MAX_LENGTH: 100
};
// ▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲
// グローバル変数
let currentPhoto = { data: null, mimeType: null };
let lineAccessToken = null;
let lineUserId = null;
let CONFIG = {};
let elements = {};
document.addEventListener('DOMContentLoaded', async function() {
try {
// 1. Cloudflareなどのエンドポイントから環境依存の設定値を取得(無ければデフォルトで続行)
let envConfig = {};
try {
const response = await fetch('/api/config', { cache: 'no-store' });
if (response.ok) {
envConfig = await response.json();
} else {
console.warn(`設定エンドポイントが見つかりませんでした: ${response.status} ${response.statusText} - デフォルト設定で起動します。`);
}
} catch (e) {
console.warn('設定エンドポイントの取得に失敗しました(オフライン/ローカル想定): デフォルト設定で起動します。', e);
}
// 2. 固定的な設定値とマージして、最終的なCONFIGオブジェクトを完成させる。
CONFIG = { ...APP_SETTINGS, ...envConfig };
console.log('アプリケーション設定が完了しました。:', CONFIG);
// 要素の取得
elements = {
map: L.map('map').setView([36.871, 140.016], 16),
coordsDisplay: document.getElementById('coords-display'),
latInput: document.getElementById('latitude'),
lngInput: document.getElementById('longitude'),
form: document.getElementById('report-form'),
btnSubmit: document.getElementById('btn-submit'),
loader: document.getElementById('loader'),
photoInput: document.getElementById('photo'),
imagePreview: document.getElementById('image-preview'),
lineStatus: document.getElementById('line-status'),
lineStatusText: document.getElementById('line-status-text'),
accessTokenInput: document.getElementById('accessToken'),
userIdInput: document.getElementById('userId'),
detailsTextarea: document.getElementById('details'), // 詳細テキストエリア
detailsRequiredNote: document.getElementById('details-required-note'), // 注釈用span
detailsOverlay: document.getElementById('details-overlay'), // 詳細ハイライト用オーバーレイ
typeRadios: document.querySelectorAll('input[name="type"]') // 異常の種類ラジオボタン(すべて)
};
// === LIFF初期化 ===
initializeLIFF();
// === 地図の初期化 ===
initializeMap(elements);
// === フォーム機能の初期化 ===
initializeFormFeatures(elements);
// 初期の送信ボタン状態を更新
updateSubmitButtonState();
} catch (error) {
console.error('初期化エラー:', error);
showNotification(error.message, 'error');
}
// === LIFF初期化関数(修正版) ===
async function initializeLIFF() {
try {
console.log('LIFF初期化開始');
if (CONFIG.LIFF_ID === 'LINE Login channelで作成したLIFF ID') {
console.warn('LIFF_IDが設定されていません');
updateLineStatus('warning', 'LIFF設定が必要です');
return;
}
await liff.init({ liffId: CONFIG.LIFF_ID });
console.log('LIFF初期化成功');
if (liff.isLoggedIn()) {
// アクセストークンを取得
lineAccessToken = liff.getAccessToken();
// プロフィール情報を取得
const profile = await liff.getProfile();
lineUserId = profile.userId;
// ↓↓↓ この一行を追加する ↓↓↓
console.log('【デバッグ用】取得したアクセストークン:', lineAccessToken);
// ↑↑↑ この一行を追加する ↑↑↑
// 隠しフィールドに設定
elements.accessTokenInput.value = lineAccessToken;
elements.userIdInput.value = lineUserId;
updateLineStatus('success', `LINE連携済み: ${profile.displayName}`);
console.log('LINEユーザー情報取得成功:', profile);
} else {
updateLineStatus('error', 'LINEログインが必要です');
console.log('LINEログインが必要');
// 自動ログインを試行
try {
await liff.login();
} catch (loginError) {
console.error('自動ログイン失敗:', loginError);
}
}
} catch (error) {
console.error('LIFF初期化エラー:', error);
updateLineStatus('error', 'LINE連携エラー');
}
}
// === LINE連携状態表示関数 ===
function updateLineStatus(status, message) {
if (!elements.lineStatus || !elements.lineStatusText) return;
elements.lineStatus.className = `line-status ${status}`;
elements.lineStatusText.textContent = message;
elements.lineStatus.classList.remove('hidden');
// 5秒後に非表示(成功時のみ)
if (status === 'success') {
setTimeout(() => {
elements.lineStatus.classList.add('hidden');
}, 5000);
}
}
// === 地図初期化関数 ===
function initializeMap(elements) {
L.tileLayer('https://cyberjapandata.gsi.go.jp/xyz/std/{z}/{x}/{y}.png', {
attribution: "地理院タイル(GSI)",
maxZoom: 18
}).addTo(elements.map);
function updateCenterCoords() {
const center = elements.map.getCenter();
elements.coordsDisplay.innerText = `緯度: ${center.lat.toFixed(6)} 経度: ${center.lng.toFixed(6)}`;
elements.latInput.value = center.lat;
elements.lngInput.value = center.lng;
// 位置が更新されたら送信ボタンの状態も更新
updateSubmitButtonState();
}
elements.map.on('move', updateCenterCoords);
updateCenterCoords();
// 現在位置の取得
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(
function(pos) {
elements.map.setView([pos.coords.latitude, pos.coords.longitude], 18);
},
function(error) {
console.warn('位置情報の取得に失敗しました:', error);
showNotification('位置情報の取得に失敗しました。手動で位置を調整してください。', 'warning');
}
);
}
}
// === フォーム機能初期化 ===
function initializeFormFeatures(elements) {
// 「その他」選択時に詳細を必須にするためのイベントリスナー
elements.typeRadios.forEach(radio => {
radio.addEventListener('change', () => {
handleTypeChange();
updateSubmitButtonState();
});
});
// 詳細の入力を日本語換算(コードポイント)で上限制御
if (elements.detailsTextarea) {
const limit = CONFIG.DETAILS_MAX_LENGTH ?? 100;
// 基本的なブラウザ側の制限(UTF-16単位)も設定
elements.detailsTextarea.setAttribute('maxlength', String(limit));
elements.detailsTextarea.addEventListener('input', () => {
const chars = Array.from(elements.detailsTextarea.value || '');
if (chars.length > limit) {
elements.detailsTextarea.value = chars.slice(0, limit).join('');
}
updateSubmitButtonState();
});
}
// 詳細文字数ハイライト・注記
if (elements.detailsTextarea && elements.detailsOverlay) {
elements.detailsTextarea.addEventListener('input', () => {
updateDetailsOverlayAndNote();
});
elements.detailsTextarea.addEventListener('scroll', () => {
// スクロール同期(transformで追従させる)
const st = elements.detailsTextarea.scrollTop;
const sl = elements.detailsTextarea.scrollLeft;
elements.detailsOverlay.style.transform = `translate(${-sl}px, ${-st}px)`;
});
// 初期描画
updateDetailsOverlayAndNote();
}
// 初期状態のチェックも実行(必須表示含めて更新)
handleTypeChange();
// 写真プレビュー
elements.photoInput.addEventListener('change', function() {
handlePhotoInput(this, elements);
});
// フォーム送信
elements.form.addEventListener('submit', function(e) {
e.preventDefault();
if (!elements.loader.classList.contains('sending')) {
const formData = new FormData(this);
handleFormSubmission(formData, elements);
}
});
// フォーム全体の変化でもボタン状態を更新(保険)
elements.form.addEventListener('input', updateSubmitButtonState);
elements.form.addEventListener('change', updateSubmitButtonState);
}
// 「異常の種類」が変更されたときのハンドラ関数
function handleTypeChange() {
const elements = { // この関数内で使う要素を再定義
detailsTextarea: document.getElementById('details'),
detailsRequiredNote: document.getElementById('details-required-note'),
otherRadio: document.getElementById('type-other') // 「その他」のラジオボタン
};
if (elements.otherRadio && elements.otherRadio.checked) {
// 「その他」が選択されている場合
elements.detailsTextarea.required = true;
// 文字数注記を含めて更新
updateDetailsNote();
} else {
// 「その他」以外が選択されている場合
elements.detailsTextarea.required = false;
// 文字数注記を含めて更新
updateDetailsNote();
}
}
// 詳細のオーバーレイ更新と注記更新
function updateDetailsOverlayAndNote() {
const textarea = document.getElementById('details');
const overlay = document.getElementById('details-overlay');
if (!textarea || !overlay) return;
const limit = CONFIG.DETAILS_MAX_LENGTH ?? 100;
const chars = Array.from(textarea.value || '');
const count = chars.length;
// HTMLエスケープ
const esc = (s) => s
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/\n/g, '<br>')
.replace(/ /g, ' ');
if (count <= limit) {
overlay.innerHTML = esc(chars.join(''));
} else {
const normal = chars.slice(0, limit).join('');
const over = chars.slice(limit).join('');
overlay.innerHTML = esc(normal) + '<span class="overflow">' + esc(over) + '</span>';
}
// スクロール位置を同期(入力時)
const st = textarea.scrollTop;
const sl = textarea.scrollLeft;
overlay.style.transform = `translate(${-sl}px, ${-st}px)`;
// ラベル注記の更新
updateDetailsNote(count > limit);
}
// ラベル注記の更新(必須と100文字注記の併記対応)
function updateDetailsNote(exceeded) {
const note = document.getElementById('details-required-note');
const otherRadio = document.getElementById('type-other');
if (!note) return;
const parts = [];
if (otherRadio && otherRadio.checked) parts.push('(必須入力)');
if (typeof exceeded === 'undefined') {
// exceededが未指定なら、現在の入力から判定
const len = Array.from((document.getElementById('details')?.value) || '').length;
const limit = CONFIG.DETAILS_MAX_LENGTH ?? 100;
if (len > limit) parts.push('(100文字以内)');
} else if (exceeded) {
parts.push('(100文字以内)');
}
note.textContent = parts.join('');
}
// === 共通ユーティリティ関数 ===
// 通知表示(統合版)
function showNotification(message, type = 'info') {
const existingNotification = document.querySelector('.notification');
if (existingNotification) existingNotification.remove();
const notification = document.createElement('div');
notification.className = `notification notification-${type}`;
notification.textContent = message;
const colors = {
success: '#10b981',
error: '#ef4444',
warning: '#f59e0b',
info: '#3b82f6'
};
notification.style.cssText = `
position: fixed; top: 20px; right: 20px; padding: 12px 20px;
border-radius: 4px; color: white; font-weight: bold; z-index: 10000;
max-width: 300px; word-wrap: break-word; overflow-wrap: break-word; box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
background-color: ${colors[type] || colors.info};
`;
document.body.appendChild(notification);
setTimeout(() => notification.remove(), 5000);
}
// 画像をビットマップ化してから再エンコード(JPEG)する共通関数
async function bitmapizeAndEncode(fileOrDataUrl, options = {}) {
const {
maxWidth = 1280,
maxHeight = 1280,
quality = 0.85,
mimeType = 'image/jpeg',
background = '#fff'
} = options;
// 入力をBlobに正規化
let srcBlob;
if (fileOrDataUrl instanceof Blob) {
srcBlob = fileOrDataUrl;
} else if (typeof fileOrDataUrl === 'string') {
// dataURLやHTTP URL想定(dataURLのみを主に想定)
const res = await fetch(fileOrDataUrl);
srcBlob = await res.blob();
} else {
throw new Error('bitmapizeAndEncode: 未対応の入力タイプです');
}
// デコードしてビットマップへ
let bmp, width, height;
try {
// createImageBitmapが使える場合はEXIFの向き適用に期待
// Safari等ではオプション未対応のためtry-catchでフォールバック
bmp = await createImageBitmap(srcBlob, { imageOrientation: 'from-image' });
width = bmp.width;
height = bmp.height;
} catch {
// フォールバック: Image要素 + ObjectURL
const url = URL.createObjectURL(srcBlob);
try {
const img = await new Promise((resolve, reject) => {
const i = new Image();
i.onload = () => resolve(i);
i.onerror = reject;
i.src = url;
});
width = img.naturalWidth || img.width;
height = img.naturalHeight || img.height;
// Canvasに描画してビットマップ化
const cvs = document.createElement('canvas');
cvs.width = width; cvs.height = height;
const ctx = cvs.getContext('2d');
ctx.drawImage(img, 0, 0);
// CanvasからImageBitmapへ(対応ブラウザのみ)
if (window.createImageBitmap) {
bmp = await createImageBitmap(cvs);
} else {
// 最低限、既にキャンバスにラスタライズ済みなのでこのまま扱う
bmp = cvs;
}
} finally {
URL.revokeObjectURL(url);
}
}
// サイズ調整(アスペクト比維持)
let targetW = width;
let targetH = height;
if (targetW > targetH) {
if (targetW > maxWidth) {
targetH = Math.round((maxWidth / targetW) * targetH);
targetW = maxWidth;
}
} else {
if (targetH > maxHeight) {
targetW = Math.round((maxHeight / targetH) * targetW);
targetH = maxHeight;
}
}
// 描画用キャンバス(OffscreenCanvasがあれば利用)
const hasOffscreen = typeof OffscreenCanvas !== 'undefined';
const cvs = hasOffscreen ? new OffscreenCanvas(targetW, targetH) : document.createElement('canvas');
if (!hasOffscreen) {
cvs.width = targetW; cvs.height = targetH;
}
const ctx = cvs.getContext('2d');
// 透明PNG/GIF対策で背景を塗る
ctx.clearRect(0, 0, targetW, targetH);
ctx.globalCompositeOperation = 'source-over';
ctx.fillStyle = background;
ctx.fillRect(0, 0, targetW, targetH);
ctx.drawImage(bmp, 0, 0, targetW, targetH);
// エンコード(JPEG)
if (cvs.convertToBlob) {
// OffscreenCanvas
const blob = await cvs.convertToBlob({ type: mimeType, quality });
return await blobToDataURL(blob);
} else {
// HTMLCanvasElement
const dataUrl = cvs.toDataURL(mimeType, quality);
return dataUrl;
}
}
function blobToDataURL(blob) {
return new Promise((resolve, reject) => {
const fr = new FileReader();
fr.onload = () => resolve(fr.result);
fr.onerror = reject;
fr.readAsDataURL(blob);
});
}
// 写真データ更新(統合版)
function updatePhoto(data, mimeType, elements) {
currentPhoto.data = data;
currentPhoto.mimeType = mimeType;
if (data && mimeType) {
elements.imagePreview.src = data;
elements.imagePreview.style.display = 'block';
} else {
elements.imagePreview.src = '#';
elements.imagePreview.style.display = 'none';
}
elements.photoInput.value = '';
}
// === 写真入力処理(画像圧縮機能付き) ===
function handlePhotoInput(input, elements) {
if (input.files && input.files[0]) {
const file = input.files[0];
// 元ファイルサイズのチェックはそのまま活かす
if (file.size > CONFIG.MAX_FILE_SIZE) {
showNotification('ファイルサイズが大きすぎます。5MB以下のファイルを選択してください。', 'error');
updatePhoto(null, null, elements);
return;
}
// ファイル形式のチェックもそのまま活かす
if (!CONFIG.ALLOWED_FILE_TYPES.includes(file.type)) {
showNotification('対応していないファイル形式です。', 'error');
updatePhoto(null, null, elements);
return;
}
// 必ずビットマップ化→再エンコード
bitmapizeAndEncode(file, { maxWidth: 1280, maxHeight: 1280, quality: 0.85, mimeType: 'image/jpeg', background: '#fff' })
.then((compressedBase64) => {
updatePhoto(compressedBase64, 'image/jpeg', elements);
console.log(`画像再エンコード完了(bitmap→jpeg): ${Math.round(compressedBase64.length / 1024)} KB`);
})
.catch((err) => {
console.error(err);
showNotification('画像の再エンコードに失敗しました。', 'error');
updatePhoto(null, null, elements);
});
}
}
// === フォーム送信処理(修正版) ===
async function handleFormSubmission(formData, elements) {
try {
setSubmissionState(true, elements);
// バリデーション
const validation = validateFormData(formData);
if (!validation.isValid) {
throw new Error(validation.message);
}
// データ送信
const result = await sendDataWithRetry(formData);
// 成功処理
showNotification('通報を受け付けました。ご協力ありがとうございます。', 'success');
elements.form.reset();
updatePhoto(null, null, elements);
} catch (error) {
console.error('送信エラー:', error);
showNotification(`送信に失敗しました: ${error.message}`, 'error');
} finally {
setSubmissionState(false, elements);
}
}
function validateFormData(formData) {
const requiredFields = [
{ name: 'latitude', label: '場所' },
{ name: 'longitude', label: '場所' },
{ name: 'type', label: '異常の種類' }
];
for (const field of requiredFields) {
const value = formData.get(field.name);
if (!value || value.trim() === '') {
return {
isValid: false,
message: field.name.includes('itude')
? '場所が指定されていません。地図を動かして位置を合わせてください。'
: `${field.label}が入力されていません。`
};
}
}
// 「その他」が選択されている場合のみ、詳細を必須チェックする
if (formData.get('type') === 'その他') {
const details = formData.get('details');
if (!details || details.trim() === '') {
return {
isValid: false,
message: '「その他」を選択した場合は、詳細を必ず入力してください。'
};
}
}
// 詳細の文字数上限チェック(入力がある場合)
const detailsAll = formData.get('details') || '';
const detailsLength = Array.from(detailsAll).length;
const limit = CONFIG.DETAILS_MAX_LENGTH ?? 100;
if (detailsLength > limit) {
return { isValid: false, message: '詳細は100文字以内で入力してください。' };
}
const lat = parseFloat(formData.get('latitude'));
const lng = parseFloat(formData.get('longitude'));
if (isNaN(lat) || lat < -90 || lat > 90) {
return { isValid: false, message: '緯度の値が正しくありません。' };
}
if (isNaN(lng) || lng < -180 || lng > 180) {
return { isValid: false, message: '経度の値が正しくありません。' };
}
return { isValid: true };
}
async function sendDataWithRetry(formData, attempt = 1) {
try {
if (!liff) {
throw new Error('LIFFが初期化されていません。');
}
const currentAccessToken = liff.getAccessToken();
if (!currentAccessToken) {
throw new Error('LINEの認証情報が取得できませんでした。')
}
const payload = {
latitude: formData.get('latitude'),
longitude: formData.get('longitude'),
type: formData.get('type'),
details: formData.get('details'),
photoData: currentPhoto.data,
photoMimeType: currentPhoto.mimeType,
accessToken: currentAccessToken, // アクセストークンを送信
userId: lineUserId, // ユーザーIDも送信(参考用)
timestamp: new Date().toISOString()
};
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), CONFIG.REQUEST_TIMEOUT);
const response = await fetch(CONFIG.GAS_WEB_APP_URL, {
method: 'POST',
body: JSON.stringify(payload),
// headers: { 'Content-Type': 'text/plain' },
// mode: 'cors',
signal: controller.signal
});
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(`サーバーエラー: ${response.status} ${response.statusText}`);
}
const data = JSON.parse(await response.text());
if (data.status === 'success') {
return data;
} else {
throw new Error(data.message || 'サーバーでエラーが発生しました。');
}
} catch (error) {
if (attempt < CONFIG.MAX_RETRY_ATTEMPTS && shouldRetry(error)) {
showNotification(`送信に失敗しました。${CONFIG.RETRY_DELAY / 1000}秒後に再試行します... (${attempt}/${CONFIG.MAX_RETRY_ATTEMPTS})`, 'warning');
await new Promise(resolve => setTimeout(resolve, CONFIG.RETRY_DELAY));
return sendDataWithRetry(formData, attempt + 1);
}
throw error;
}
}
function shouldRetry(error) {
return error.name === 'AbortError' ||
error.message.includes('fetch') ||
error.message.includes('network') ||
error.message.includes('timeout');
}
function setSubmissionState(isSending, elements) {
if (isSending) {
elements.loader.classList.remove('hidden');
elements.loader.classList.add('sending');
} else {
elements.loader.classList.add('hidden');
elements.loader.classList.remove('sending');
}
const formElements = elements.form.querySelectorAll('input, select, textarea, button');
formElements.forEach(el => el.disabled = isSending);
// 送信状態変更後にもボタン表示テキストを適切に更新
if (!isSending) updateSubmitButtonState();
}
// 送信ボタンの活性/非活性とテキストを更新
function updateSubmitButtonState() {
if (!elements?.form || !elements?.btnSubmit) return;
// 送信中は一律で制御しない
if (elements.loader?.classList.contains('sending')) return;
const formData = new FormData(elements.form);
const selectedType = formData.get('type');
const isOther = selectedType === 'その他';
const detailsVal = (elements.detailsTextarea?.value || '').trim();
const canSubmit = selectedType && (!isOther || (isOther && detailsVal.length > 0));
elements.btnSubmit.disabled = !canSubmit;
elements.btnSubmit.textContent = canSubmit
? 'この内容で通報する'
: '不具合の種類を選択してください';
}
});
style.css
- 以下のとおり
createStaffTable
/* ===== リセットとベーススタイル ===== */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica Neue', Arial, sans-serif;
line-height: 1.6;
color: #333;
background-color: #f8f9fa;
font-size: 16px;
}
/* ===== コンテナとレイアウト ===== */
.container {
max-width: 600px;
margin: 0 auto;
padding: 20px;
background-color: #ffffff;
border-radius: 12px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
margin-top: 20px;
margin-bottom: 20px;
}
/* ===== ヘッダー ===== */
h2 {
text-align: center;
margin-bottom: 30px;
color: #2c3e50;
font-size: 24px;
font-weight: 700;
padding-bottom: 15px;
border-bottom: 3px solid #3498db;
}
/* ===== LINE連携状態表示(修正版) ===== */
.line-status {
margin-bottom: 24px;
padding: 12px 16px;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
display: flex;
align-items: center;
gap: 10px;
border: 1px solid;
animation: slideDown 0.3s ease-out;
}
@keyframes slideDown {
from {
transform: translateY(-10px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.line-status.success {
background-color: #d4edda;
color: #155724;
border-color: #c3e6cb;
}
.line-status.error {
background-color: #f8d7da;
color: #721c24;
border-color: #f5c6cb;
}
.line-status.warning {
background-color: #fff3cd;
color: #856404;
border-color: #ffeaa7;
}
.line-icon {
font-size: 16px;
color: #00c300;
flex-shrink: 0;
}
.line-status-content {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
}
.hidden {
display: none !important;
}
/* ===== フォームグループ ===== */
.form-group {
margin-bottom: 30px;
}
.form-label {
display: block;
margin-bottom: 12px;
font-weight: 600;
color: #2c3e50;
font-size: 16px;
}
.label-note {
font-size: 12px;
font-weight: normal;
color: #e74c3c; /* 少し目立つ色に */
margin-left: 8px;
}
/* ===== ラジオボタン(改善版) ===== */
.radio-group {
display: flex;
flex-direction: column;
gap: 12px;
}
.radio-item {
display: flex;
align-items: center;
padding: 12px 16px;
border: 2px solid #e9ecef;
border-radius: 8px;
cursor: pointer;
transition: all 0.3s ease;
background-color: #ffffff;
}
.radio-item:hover {
border-color: #3498db;
background-color: #f8f9fa;
}
.radio-item input[type="radio"] {
margin: 0;
margin-right: 12px;
width: 18px;
height: 18px;
accent-color: #3498db;
cursor: pointer;
}
.radio-item label {
cursor: pointer;
font-size: 15px;
font-weight: 500;
color: #2c3e50;
flex: 1;
}
.radio-item input[type="radio"]:checked + label,
.radio-item:has(input[type="radio"]:checked) {
color: #3498db;
font-weight: 600;
}
.radio-item:has(input[type="radio"]:checked) {
border-color: #3498db;
background-color: #e3f2fd;
}
/* ===== テキストエリア ===== */
textarea {
width: 100%;
min-height: 100px;
padding: 12px 16px;
border: 2px solid #e9ecef;
border-radius: 8px;
font-size: 15px;
font-family: inherit;
resize: vertical;
transition: border-color 0.3s ease;
}
/* === テキストエリアのハイライト用オーバーレイ === */
.textarea-wrapper {
position: relative;
}
.textarea-wrapper textarea {
position: relative;
background: transparent; /* オーバーレイと重ねるため */
z-index: 1;
}
.textarea-overlay {
position: absolute;
inset: 0;
z-index: 2;
padding: 12px 16px; /* textareaと合わせる */
border: 2px solid transparent; /* レイアウト合わせ用 */
border-radius: 8px;
font-size: 15px;
font-family: inherit;
line-height: 1.6;
white-space: pre-wrap;
word-wrap: break-word;
color: transparent; /* 通常文字は見えない(下のtextareaを見せる) */
pointer-events: none; /* 操作はtextareaに通す */
overflow: hidden;
}
.textarea-overlay .overflow {
color: #e74c3c; /* 上限超過部分を赤く表示 */
}
textarea:focus {
outline: none;
border-color: #3498db;
box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.1);
}
textarea::placeholder {
color: #6c757d;
font-style: italic;
}
/* ===== 写真コントロール ===== */
.photo-controls {
display: flex;
gap: 12px;
margin-bottom: 15px;
flex-wrap: wrap;
}
.button-like-input {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 16px;
background-color: #6c757d;
color: white;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
text-decoration: none;
}
.button-like-input:hover {
background-color: #5a6268;
transform: translateY(-1px);
}
/* ===== 画像プレビュー ===== */
#image-preview {
display: none;
max-width: 100%;
height: auto;
border-radius: 8px;
border: 2px solid #e9ecef;
margin-top: 15px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
/* ===== 地図セクション(修正版) ===== */
#map-wrapper {
position: relative;
width: 100%;
height: 300px;
border: 2px solid #e9ecef;
border-radius: 8px;
overflow: hidden;
margin-bottom: 15px;
background-color: #f8f9fa;
}
#map {
width: 100%;
height: 100%;
z-index: 1;
}
#center-pin {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -100%);
font-size: 24px;
color: #e74c3c;
z-index: 1000;
pointer-events: none;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
}
#coords-display {
padding: 10px 12px;
background-color: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 6px;
font-size: 14px;
color: #495057;
text-align: center;
font-family: 'Courier New', monospace;
}
/* ===== 送信ボタン ===== */
#btn-submit {
width: 100%;
padding: 18px 28px;
min-height: 56px;
background: linear-gradient(135deg, #3498db 0%, #2980b9 100%);
color: white;
border: none;
border-radius: 8px;
font-size: 17px;
font-weight: 700;
cursor: pointer;
transition: all 0.3s ease;
margin-top: 20px;
box-shadow: 0 4px 6px rgba(52, 152, 219, 0.3);
}
#btn-submit:hover:not(:disabled) {
background: linear-gradient(135deg, #2980b9 0%, #1f5f8b 100%);
transform: translateY(-2px);
box-shadow: 0 6px 12px rgba(52, 152, 219, 0.4);
}
#btn-submit:disabled {
background: linear-gradient(135deg, #95a5a6 0%, #7f8c8d 100%);
cursor: not-allowed;
transform: none;
box-shadow: none;
}
/* ===== ローディング画面 ===== */
.loader-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.7);
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
z-index: 9999;
}
.loader {
width: 50px;
height: 50px;
border: 5px solid #f3f3f3;
border-top: 5px solid #3498db;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.loader-text {
margin-top: 20px;
color: white;
font-size: 16px;
font-weight: 600;
}
/* ===== レスポンシブ対応 ===== */
@media (max-width: 768px) {
.container {
margin: 10px;
padding: 16px;
border-radius: 8px;
}
h2 {
font-size: 20px;
margin-bottom: 24px;
}
.form-group {
margin-bottom: 24px;
}
.photo-controls {
flex-direction: column;
}
.button-like-input {
width: 100%;
justify-content: center;
}
#map-wrapper {
height: 250px;
}
}
@media (max-width: 480px) {
.container {
margin: 5px;
padding: 12px;
}
h2 {
font-size: 18px;
}
.form-label {
font-size: 15px;
}
.radio-item {
padding: 10px 12px;
}
.radio-item label {
font-size: 14px;
}
#map-wrapper {
height: 200px;
}
#btn-submit {
padding: 16px 24px;
min-height: 56px;
font-size: 16px;
}
}
/* ===== 通知スタイル ===== */
.notification {
position: fixed;
top: 20px;
right: 20px;
padding: 12px 20px;
border-radius: 6px;
color: white;
font-weight: 600;
z-index: 10000;
max-width: 300px;
word-wrap: break-word;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
animation: slideInRight 0.3s ease-out;
}
@keyframes slideInRight {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
.notification-success {
background-color: #28a745;
}
.notification-error {
background-color: #dc3545;
}
.notification-warning {
background-color: #ffc107;
color: #212529;
}
.notification-info {
background-color: #17a2b8;
}
/* ===== アクセシビリティ改善 ===== */
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
/* ===== フォーカス状態の改善 ===== */
button:focus,
input:focus,
textarea:focus,
.radio-item:focus-within {
outline: 2px solid #3498db;
outline-offset: 2px;
}
/* ===== 印刷スタイル ===== */
@media print {
.photo-controls,
#btn-submit,
.loader-overlay {
display: none !important;
}
.container {
box-shadow: none;
border: 1px solid #000;
}
#map-wrapper {
border: 1px solid #000;
background-color: #f0f0f0;
}
}
code(backend)
- gasです。
code.gs
- 以下のとおり
createStaffTable
/**
* 道路通報システム用Google Apps Script(ログ確認しやすい版)
* レスポンスにログを含める + スプレッドシートログ出力対応
* 【改修版】console.logをlog関数に統一
*/
const scriptProperties = PropertiesService.getScriptProperties();
// ▼▼▼【重要】設定値を更新してください ▼▼▼
const CONFIG = {
SPREADSHEET_ID: scriptProperties.getProperty('SPREADSHEET_ID'),
// Messaging APIチャネルのアクセストークン
LINE_MESSAGING_ACCESS_TOKEN: scriptProperties.getProperty('LINE_CHANNEL_ACCESS_TOKEN'),
// LINE Login channelの設定
LINE_LOGIN_CHANNEL_ID: scriptProperties.getProperty('LINE_LOGIN_CHANNEL_ID'), // ← 実際のチャネルIDに変更
LINE_LOGIN_CHANNEL_SECRET: scriptProperties.getProperty('LINE_LOGIN_CHANNEL_SECRET'), // ← 実際のシークレットに変更
// API URL
LINE_MESSAGING_API_URL: 'https://api.line.me/v2/bot/message/push',
LINE_PROFILE_API_URL: 'https://api.line.me/v2/profile',
LINE_VERIFY_TOKEN_URL: 'https://api.line.me/oauth2/v2.1/verify',
// Google Drive設定
DRIVE_FOLDER_ID: scriptProperties.getProperty('DRIVE_FOLDER_ID'), // ← 実際のDriveフォルダIDに変更
// ログ出力設定
LOG_TO_RESPONSE: true, // レスポンスにログを含める
LOG_TO_SPREADSHEET: true, // スプレッドシートにログ出力(オプション )
};
// ▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲
// ログ収集用配列
let debugLogs = [];
/**
* ログ出力関数(console.log + 配列に保存)
* 【改修】エラーレベル対応を追加
*/
function log(message, data = null, level = 'INFO') {
const timestamp = new Date().toISOString();
const logEntry = {
timestamp: timestamp,
level: level,
message: message,
data: data
};
// レベルに応じてコンソール出力を使い分け
if (level === 'ERROR') {
if (data) {
console.error(`[${timestamp}] ${message}:`, data);
} else {
console.error(`[${timestamp}] ${message}`);
}
} else if (level === 'WARN') {
if (data) {
console.warn(`[${timestamp}] ${message}:`, data);
} else {
console.warn(`[${timestamp}] ${message}`);
}
} else {
// INFO レベル(デフォルト)
if (data) {
console.log(`[${timestamp}] ${message}:`, data);
} else {
console.log(`[${timestamp}] ${message}`);
}
}
// 配列にも保存
debugLogs.push(logEntry);
// スプレッドシートにログ出力(オプション)
if (CONFIG.LOG_TO_SPREADSHEET) {
try {
logToSpreadsheet(message, data, level);
} catch (error) {
// logToSpreadsheet内でのエラーは console.error を直接使用(無限再帰回避)
console.error('スプレッドシートログ出力エラー:', error);
MailApp.sendEmail("vacation555@gmail.com", "logToSpreadsheet", "logToSpreadsheet");
}
}
}
/**
* GETリクエストの処理(設定確認機能付き)
*/
function doGet(e) {
try {
debugLogs = []; // ログ配列をリセット
log('1. === GET request received ===');
// 設定状況を確認
const configStatus = {
hasSpreadsheetId: !!CONFIG.SPREADSHEET_ID,
hasLineToken: CONFIG.LINE_MESSAGING_ACCESS_TOKEN !== 'LINE_CHANNEL_ACCESS_TOKEN',
hasChannelId: CONFIG.LINE_LOGIN_CHANNEL_ID !== 'LINE_LOGIN_CHANNEL_ID',
hasChannelSecret: CONFIG.LINE_LOGIN_CHANNEL_SECRET !== 'LINE_LOGIN_CHANNEL_SECRET',
hasDriveFolderId: CONFIG.DRIVE_FOLDER_ID !== 'DRIVE_FOLDER_ID'
};
log('2. 設定状況確認', configStatus);
// 詳細設定情報
const detailConfig = {
spreadsheetId: CONFIG.SPREADSHEET_ID,
lineTokenConfigured: CONFIG.LINE_MESSAGING_ACCESS_TOKEN !== 'LINE_CHANNEL_ACCESS_TOKEN',
lineTokenPrefix: CONFIG.LINE_MESSAGING_ACCESS_TOKEN.substring(0, 10) + '...',
channelId: CONFIG.LINE_LOGIN_CHANNEL_ID,
channelIdConfigured: CONFIG.LINE_LOGIN_CHANNEL_ID !== 'LINE_LOGIN_CHANNEL_ID',
driveFolderId: CONFIG.DRIVE_FOLDER_ID,
driveFolderConfigured: CONFIG.DRIVE_FOLDER_ID !== 'DRIVE_FOLDER_ID'
};
log('3. 詳細設定情報', detailConfig);
return ContentService.createTextOutput(JSON.stringify({
status: 'success',
message: 'API is working',
timestamp: new Date().toISOString(),
config: configStatus,
detailConfig: detailConfig,
logs: CONFIG.LOG_TO_RESPONSE ? debugLogs : []
})).setMimeType(ContentService.MimeType.JSON);
} catch (error) {
log('4. GET request error', error.toString(), 'ERROR');
return ContentService.createTextOutput(JSON.stringify({
status: 'error',
message: error.toString(),
logs: CONFIG.LOG_TO_RESPONSE ? debugLogs : []
}))
.setMimeType(ContentService.MimeType.JSON)
.addHeader("Access-Control-Allow-Origin", "https://report-app-about-road.pages.dev" );
}
}
/**
* 受け取ったデータを検証し、安全な形にサニタイズする
* @param {object} rawData - JSON.parseされた生のデータ
* @returns {object} 検証・サニタイズ済みのデータ
*/
function validateAndSanitizeData(rawData) {
// --- 必須フィールドの検証 ---
const latitude = parseFloat(rawData.latitude);
const longitude = parseFloat(rawData.longitude);
if (isNaN(latitude) || isNaN(longitude) || !rawData.type) {
throw new Error('必須フィールド(緯度、経度、種別)が無効または不足しています。');
}
// --- photoDataの検証 ---
if (rawData.photoData) {
// 1. データサイズのチェック (5MBを上限とする)
const MAX_PHOTO_LENGTH = 5 * 1024 * 1024 * 1.4; // Base64はサイズが約1.37倍になるため少し余裕を持たせる
if (rawData.photoData.length > MAX_PHOTO_LENGTH) {
throw new Error('画像サイズが大きすぎます。5MB以下の画像をアップロードしてください。');
}
// 2. MIMEタイプのチェック (JPEG, PNG, GIFのみ許可)
if (!rawData.photoData.startsWith('data:image/')) {
throw new Error('無効な画像データ形式です。');
}
const mimeType = rawData.photoData.substring(5, rawData.photoData.indexOf(';'));
const allowedMimes = ['image/jpeg', 'image/png', 'image/gif'];
if (!allowedMimes.includes(mimeType)) {
throw new Error(`許可されていない画像形式です: ${mimeType}`);
}
}
// --- サニタイズ処理 ---
let photoMimeType = null;
if (rawData.photoData) {
photoMimeType = rawData.photoData.substring(5, rawData.photoData.indexOf(';'));
}
const sanitizedData = {
latitude: latitude,
longitude: longitude,
type: sanitizeText(rawData.type),
details: rawData.details ? sanitizeText(rawData.details) : '',
photoData: rawData.photoData || null,
photoMimeType: photoMimeType,
accessToken: rawData.accessToken || null
};
return sanitizedData;
}
/**
* 文字列をサニタイズして、HTMLタグとして解釈されないようにする
* @param {string} text - サニタイズする文字列
* @returns {string} サニタイズ後の文字列
*/
function sanitizeText(text) {
if (typeof text !== 'string') {
return text;
}
return text.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
/**
* POSTリクエストを処理するメイン関数
*/
function doPost(e) {
// ログシートをクリアする(LOG_TO_SPREADSHEETがtrueの場合のみ)
if (CONFIG.LOG_TO_SPREADSHEET) {
try {
const spreadsheet = SpreadsheetApp.openById(CONFIG.SPREADSHEET_ID);
const logSheet = spreadsheet.getSheetByName('ログ');
if (logSheet) {
// ヘッダー行(1行目)を残して、2行目以降の全データをクリア
logSheet.getRange(2, 1, logSheet.getLastRow(), logSheet.getLastColumn()).clearContent();
log('ログシートをクリアしました。');
}
} catch (clearError) {
log('ログシートのクリアに失敗しました', { error: clearError.toString() }, 'ERROR');
// クリアに失敗してもメインの処理は続行する
}
}
try {
debugLogs = []; // ログ配列をリセット
log('5. === POST request received ===');
// POSTデータの取得
if (!e.postData || !e.postData.contents) {
throw new Error('POSTデータが見つかりません');
}
const rawData = JSON.parse(e.postData.contents);
log('6. Request data received (raw)', { /* ... */ });
// ★ 1. データの検証とサニタイズ
log('7. === 1. データ検証とサニタイズ開始 ===');
const validatedData = validateAndSanitizeData(rawData);
log('8. データ検証・サニタイズ完了');
let userId = null;
let lineResult = null;
// --- 2. 先にユーザー認証を行う ---
const hasAccessToken = validatedData.accessToken && validatedData.accessToken.trim() !== '';
const canAttemptLineAuth = hasAccessToken && CONFIG.LINE_MESSAGING_ACCESS_TOKEN && CONFIG.LINE_LOGIN_CHANNEL_ID;
if(canAttemptLineAuth){
log('14. === LINEユーザー認証を開始します ===');
try{
userId = getUserIdFromAccessToken(validatedData.accessToken);
log('16. ユーザーID取得成功',{userId: userId});
}catch(authError){
log('20a. LINEユーザー認証エラー', authError.toString(), 'ERROR');
// 認証に失敗した場合、ここで処理を中断させる
throw new Error('ユーザー認証に失敗しました。有効なアクセストークンが必要です。');
}
}else{
// アクセストークンがない場合も処理を中断
throw new Error('アクセストークンが見つかりません。認証が必要です。');
}
// ___ 3. 認証成功後にデータベース処理を行う ___
// 認証で取得したuserIdをデータに追加
validatedData.userId = userId;
log('9. ===2.スプレッドシートに保存開始 ===');
const saveResult = saveToSpreadsheet(validatedData);
log('10. スプレッドシート保存結果', saveResult);
// ___ 4. 認証成功後にLINE通知を行う ___
log('17. LINE投稿開始');
lineResult = sendLineMessage(userId, validatedData, saveResult);
log('18. LINE投稿成功', lineResult);
// ___5. 認証成功後にメール通知を行う___
try{
sendNotificationEmail(validatedData, saveResult);
}catch(mailError){
log('21d. メール送信エラー', {error: mailError.toString()}, 'ERROR');
}
// 成功レスポンス
const response = {
status: 'success',
message: '通報を受け付けました。ご協力ありがとうございます。',
timestamp: new Date().toISOString(),
id: saveResult.id,
lineNotified: lineResult !== null,
imageUploaded: !!saveResult.photoUrl,
debug: { /* ... */ },
logs: CONFIG.LOG_TO_RESPONSE ? debugLogs : []
};
log('22. 最終レスポンス準備完了', { /* ... */ });
const responseOutput = ContentService.createTextOutput(JSON.stringify(response))
.setMimeType(ContentService.MimeType.JSON);
// ★★★ 最終的にこの変数を返すように変更 ★★★
return responseOutput; // この行を追加
} catch (error) {
log('23. doPost エラー', error.toString(), 'ERROR');
const errorResponse = {
status: 'error',
message: 'データの処理に失敗しました: ' + error.toString(),
timestamp: new Date().toISOString(),
logs: CONFIG.LOG_TO_RESPONSE ? debugLogs : []
};
const errorOutput = ContentService.createTextOutput(JSON.stringify(errorResponse))
.setMimeType(ContentService.MimeType.JSON);
// ★★★ 最終的にこの変数を返すように変更 ★★★
return errorOutput; // この行を追加
}
}
function sendNotificationEmail(validatedData, saveResult) {
try{
const spreadSheet = SpreadsheetApp.openById(CONFIG.SPREADSHEET_ID);
const mailSheet = spreadSheet.getSheetByName('メール');
if(!mailSheet){
throw new Error('「メール」シートがみつかりません。');
}
const lastRow = mailSheet.getLastRow();
let recipients = [];
if(lastRow > 1){
recipients = mailSheet.getRange(2, 2, lastRow -1, 1) // 1. B列のアドレス範囲を取得
.getValues() // 2. 値を二次元配列として取得
.flat() // 3. 配列を一次元化(平坦化)
.filter(email => email && email.includes('@')); // 4. 有効なアドレスだけを抽出
}
if(recipients.length > 0){
const subject = `【道路通報】新規通報(種別:${validatedData.type})`;
let mailBody = "新しい道路通報がありましたので、お知らせします。\n\n";
mailBody += "----------------------------------------\n";
mailBody += "■ 通報内容\n";
mailBody += "----------------------------------------\n";
mailBody += `・受付日時: ${new Date(saveResult.timestamp).toLocaleString('ja-JP')}\n`;
mailBody += `・通報種別: ${validatedData.type}\n`;
mailBody += `・詳細: ${validatedData.details || '記載なし'}\n\n`;
mailBody += `・場所の確認(Googleマップ):\n${saveResult.googleMapLink}\n\n`;
if (saveResult.photoUrl && !saveResult.photoUrl.includes('エラー')) {
mailBody += `・写真の確認:\n${saveResult.photoUrl}\n\n`;
} else {
mailBody += "・写真: なし\n\n";
}
mailBody += "----------------------------------------\n";
// メールを送信
MailApp.sendEmail(recipients.join(','), subject, mailBody);
log('21b. 通知メールを送信しました。', { recipients: recipients });
} else {
log('21c. メールの宛先が設定されていないため、通知をスキップしました。');
}
}catch(mailError){
log('21d. メール送信エラー', {error: mailError.toString()}, 'ERROR');
throw mailError;
}
}
/**
* アクセストークンからユーザーIDを取得(ログ強化版)
*/
function getUserIdFromAccessToken(accessToken) {
try {
log('24. === ユーザーID取得開始 ===');
// 1. アクセストークンの検証
log('25. アクセストークン検証開始');
const verifyResponse = UrlFetchApp.fetch(
`${CONFIG.LINE_VERIFY_TOKEN_URL}?access_token=${accessToken}`,
{
method: 'GET',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
}
}
);
const verifyResponseCode = verifyResponse.getResponseCode();
log('26. 検証レスポンスコード', verifyResponseCode);
if (verifyResponseCode !== 200) {
const errorText = verifyResponse.getContentText();
log('27. アクセストークン検証失敗', errorText, 'ERROR');
throw new Error('アクセストークンの検証に失敗しました: ' + errorText);
}
const verifyData = JSON.parse(verifyResponse.getContentText());
log('28. 検証結果', verifyData);
// チャネルIDの確認
if (verifyData.client_id !== CONFIG.LINE_LOGIN_CHANNEL_ID) {
const mismatchInfo = {
expected: CONFIG.LINE_LOGIN_CHANNEL_ID,
actual: verifyData.client_id
};
log('29. チャネルID不一致', mismatchInfo, 'ERROR');
throw new Error(`チャネルIDが一致しません。期待値: ${CONFIG.LINE_LOGIN_CHANNEL_ID}, 実際: ${verifyData.client_id}`);
}
log('30. ユーザープロフィール取得開始');
const profileResponse = UrlFetchApp.fetch(CONFIG.LINE_PROFILE_API_URL, {
method: 'GET',
headers: {
'Authorization': 'Bearer ' + accessToken
}
});
const profileResponseCode = profileResponse.getResponseCode();
log('31. プロフィールレスポンスコード', profileResponseCode);
if (profileResponseCode !== 200) {
const errorText = profileResponse.getContentText();
log('32. プロフィール取得失敗', errorText, 'ERROR');
throw new Error('ユーザープロフィールの取得に失敗しました: ' + errorText);
}
const profileData = JSON.parse(profileResponse.getContentText());
log('33. プロフィール取得成功', profileData);
return profileData.userId;
} catch (error) {
log('34. ユーザーID取得エラー', error.toString(), 'ERROR');
throw error;
}
}
/**
* スプレッドシートにデータを保存(ログ強化版)
*/
function saveToSpreadsheet(data) {
try {
log('35. スプレッドシート保存開始');
const spreadsheet = SpreadsheetApp.openById(CONFIG.SPREADSHEET_ID);
const sheet = spreadsheet.getSheetByName('通報データ');
// ヘッダー行が存在しない場合は作成
if (sheet.getLastRow() === 0) {
const headers = [
'受付日時',
'緯度',
'経度',
'地図',
'通報種別',
'詳細',
'写真',
'LINEユーザーID'
];
sheet.getRange(1, 1, 1, headers.length).setValues([headers]);
log('36. ヘッダー行を作成');
}
// 写真データの処理(DRIVE_FOLDER_ID対応)
let photoUrl = '';
if (data.photoData && data.photoMimeType) {
try {
log('37. 写真データ処理開始', {
mimeType: data.photoMimeType,
dataLength: data.photoData.length
});
// Base64データからBlobを作成
const base64Data = data.photoData.split(',')[1] || data.photoData;
const blob = Utilities.newBlob(
Utilities.base64Decode(base64Data),
data.photoMimeType,
'report_photo_' + new Date().getTime() + '.jpg'
);
let file;
// DRIVE_FOLDER_IDが設定されている場合は指定フォルダに保存
if (CONFIG.DRIVE_FOLDER_ID && CONFIG.DRIVE_FOLDER_ID !== 'DRIVE_FOLDER_ID') {
try {
const folder = DriveApp.getFolderById(CONFIG.DRIVE_FOLDER_ID);
file = folder.createFile(blob);
log('38. 写真をフォルダに保存', { folderName: folder.getName() });
} catch (folderError) {
log('39. フォルダアクセス失敗、ルートフォルダに保存', folderError.toString(), 'WARN');
file = DriveApp.createFile(blob);
}
} else {
// DRIVE_FOLDER_IDが設定されていない場合はルートフォルダに保存
file = DriveApp.createFile(blob);
log('40. 写真をルートフォルダに保存');
}
// ファイルを公開設定
file.setSharing(DriveApp.Access.ANYONE_WITH_LINK, DriveApp.Permission.VIEW);
// 公開URLを取得
photoUrl = file.getUrl();
log('41. 写真保存成功', { url: photoUrl });
} catch (photoError) {
log('42. 写真保存エラー', photoError.toString(), 'ERROR');
photoUrl = '写真保存エラー: ' + photoError.toString();
}
}
// Googleマップのリンク
const googleMapLink = `https://www.google.com/maps/search/?api=1&query=${data.latitude},${data.longitude}`;
// データ行を追加
const rowData = [
new Date( ),
data.latitude,
data.longitude,
googleMapLink,
data.type,
data.details || '',
photoUrl,
data.userId || ''
];
const newRow = sheet.getLastRow() + 1;
sheet.getRange(newRow, 1, 1, rowData.length).setValues([rowData]);
log('43. スプレッドシート保存完了', { row: newRow });
return {
id: newRow,
timestamp: new Date(),
photoUrl: photoUrl,
googleMapLink: googleMapLink
};
} catch (error) {
log('44. スプレッドシート保存エラー', error.toString(), 'ERROR');
throw new Error('スプレッドシートへの保存に失敗しました: ' + error.toString());
}
}
/**
* LINEにプッシュメッセージを送信(ログ強化版)
*/
function sendLineMessage(userId, reportData, saveResult) {
try {
log('45. LINE投稿開始', { userId: userId });
const headers = {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + CONFIG.LINE_MESSAGING_ACCESS_TOKEN
};
// メッセージ配列を作成
const messages = [];
// 1. Flex Message(構造化メッセージ)
const flexMessage = createFlexMessage(reportData, saveResult.photoUrl);
messages.push(flexMessage);
log('46. Flex Message追加');
// 2. 位置情報メッセージ
const locationMessage = {
type: 'location',
title: '通報場所',
address: `緯度: ${reportData.latitude}, 経度: ${reportData.longitude}`,
latitude: parseFloat(reportData.latitude),
longitude: parseFloat(reportData.longitude)
};
messages.push(locationMessage);
log('47. 位置情報メッセージ追加');
// 3. 写真メッセージ(ある場合)
if (saveResult.photoUrl && !saveResult.photoUrl.includes('エラー')) {
// Google DriveのURLを画像表示用に変換
const imageUrl = convertDriveUrlToImageUrl(saveResult.photoUrl);
const imageMessage = {
type: 'image',
originalContentUrl: imageUrl,
previewImageUrl: imageUrl
};
messages.push(imageMessage);
log('48. 画像メッセージ追加', { imageUrl: imageUrl });
}
// 4. 補足テキストメッセージ
const textMessage = {
type: 'text',
text: createLineTextMessage(reportData, saveResult.googleMapLink, saveResult.photoUrl)
};
messages.push(textMessage);
log('49. テキストメッセージ追加');
const payload = {
'to': userId,
'messages': messages
};
log('50. 送信準備完了', { messageCount: messages.length });
const options = {
'method': 'POST',
'headers': headers,
'payload': JSON.stringify(payload)
};
const response = UrlFetchApp.fetch(CONFIG.LINE_MESSAGING_API_URL, options);
const responseCode = response.getResponseCode();
log('51. LINE APIレスポンス', { code: responseCode });
if (responseCode === 200) {
log('52. LINE API成功');
return { success: true, messageCount: messages.length };
} else {
const errorText = response.getContentText();
log('53. LINE APIエラー', { code: responseCode, error: errorText }, 'ERROR');
throw new Error(`LINE API エラー: ${responseCode} - ${errorText}`);
}
} catch (error) {
log('54. LINE送信エラー', error.toString(), 'ERROR');
throw error;
}
}
/**
* Flex Message(構造化メッセージ)を作成
*/
function createFlexMessage(data, photoUrl) {
return {
type: 'flex',
altText: '道路異状通報を受け付けました',
contents: {
type: 'bubble',
header: {
type: 'box',
layout: 'vertical',
contents: [
{
type: 'text',
text: '🚧 道路異状通報',
weight: 'bold',
color: '#ffffff',
size: 'lg'
},
{
type: 'text',
text: '受付完了',
color: '#ffffff',
size: 'sm'
}
],
backgroundColor: '#3498db',
paddingAll: 'lg'
},
body: {
type: 'box',
layout: 'vertical',
contents: [
{
type: 'box',
layout: 'vertical',
contents: [
{
type: 'text',
text: '受付日時',
color: '#666666',
size: 'sm'
},
{
type: 'text',
text: new Date().toLocaleString('ja-JP'),
weight: 'bold',
size: 'md',
margin: 'xs'
}
],
margin: 'md'
},
{
type: 'box',
layout: 'vertical',
contents: [
{
type: 'text',
text: '通報種別',
color: '#666666',
size: 'sm'
},
{
type: 'text',
text: data.type,
weight: 'bold',
size: 'md',
margin: 'xs',
color: '#e74c3c'
}
],
margin: 'md'
},
{
type: 'box',
layout: 'vertical',
contents: [
{
type: 'text',
text: '詳細情報',
color: '#666666',
size: 'sm'
},
{
type: 'text',
text: data.details || '記載なし',
size: 'md',
margin: 'xs',
wrap: true
}
],
margin: 'md'
}
]
},
footer: {
type: 'box',
layout: 'vertical',
contents: [
{
type: 'button',
style: 'primary',
action: {
type: 'uri',
label: '🗺️ 地図で確認',
uri: `https://www.google.com/maps?q=${data.latitude},${data.longitude}`
},
color: '#27ae60'
}
],
margin: 'md'
}
}
};
}
/**
* LINEテキストメッセージの内容を作成
*/
function createLineTextMessage(data, mapLink, photoLink ) {
const timestamp = new Date().toLocaleString('ja-JP', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
let message = `📋 通報詳細\n\n`;
message += `🔸 種別: ${data.type}\n`;
message += `🔸 詳細: ${data.details || '記載なし'}\n`;
message += `🔸 受付日時: ${timestamp}\n\n`;
if (mapLink) {
message += `📍 場所の確認:\n${mapLink}\n\n`;
}
if (photoLink && !photoLink.includes('エラー')) {
message += `📷 写真の確認:\n${photoLink}\n\n`;
}
message += `📍 この通報は担当部署で確認し、適切に対応いたします。\n`;
message += `ご協力ありがとうございました。`;
return message;
}
/**
* Google DriveのURLを画像表示用URLに変換
*/
function convertDriveUrlToImageUrl(driveUrl) {
try {
// Google DriveのファイルIDを抽出
const fileIdMatch = driveUrl.match(/\/d\/([a-zA-Z0-9-_]+)/);
if (fileIdMatch) {
const fileId = fileIdMatch[1];
return `https://drive.google.com/uc?id=${fileId}`;
// return `https://drive.google.com/uc?export=view&id=${fileId}`;
}
// 既に変換済みの場合はそのまま返す
if (driveUrl.includes('drive.google.com/uc?id=' )) {
return driveUrl;
}
// その他の場合はそのまま返す
return driveUrl;
} catch (error) {
log('55. URL変換エラー', error.toString(), 'ERROR');
return driveUrl;
}
}
/**
* スプレッドシートにログ出力(オプション機能)
* 【改修】エラーレベル対応を追加
*/
function logToSpreadsheet(message, data, level = 'INFO') {
try {
if(!CONFIG.SPREADSHEET_ID){
// logToSpreadsheet内では console.log を直接使用(無限再帰回避)
console.log('スプレッドシートID未設定のため、ログ出力をスキップします。');
return;
}
const spreadsheet = SpreadsheetApp.openById(CONFIG.SPREADSHEET_ID);
let logSheet = spreadsheet.getSheetByName('ログ');
// ログシートが存在しない場合は作成
if (logSheet === null) {
logSheet = spreadsheet.insertSheet('ログ');
const headers = ['日時', 'レベル', 'メッセージ', 'データ'];
logSheet.getRange(1, 1, 1, headers.length).setValues([headers]);
}
const rowData = [
new Date(),
level,
message,
data ? JSON.stringify(data) : ''
];
logSheet.appendRow(rowData);
} catch (error) {
// logToSpreadsheet内でのエラーは console.error を直接使用(無限再帰回避)
console.error('スプレッドシートログ出力エラー:', error);
try {
const recipient = "vacation555@gmail.com"; // ★★★ あなたのメールアドレスに変更 ★★★
const subject = "【GAS緊急エラー】logToSpreadsheetでエラー発生";
let body = "スプレッドシートへのログ書き込み中にエラーが発生しました。\n\n";
body += "----------------------------------------\n";
body += "エラー詳細\n";
body += "----------------------------------------\n";
body += "メッセージ: " + error.message + "\n\n";
body += "スタックトレース:\n" + error.stack + "\n\n";
body += "書き込もうとしたデータ:\n";
body += "メッセージ: " + message + "\n";
body += "データ: " + JSON.stringify(data) + "\n";
body += "レベル: " + level + "\n";
MailApp.sendEmail(recipient, subject, body);
} catch (mailError) {
// メール送信自体に失敗した場合のログ
console.error('エラー通知メールの送信に失敗しました:', mailError);
}
}
}
/**
* 設定確認用関数(ログ強化版)
*/
function checkConfiguration() {
debugLogs = []; // ログ配列をリセット
log('56. === 設定確認開始 ===');
const config = {
spreadsheetId: CONFIG.SPREADSHEET_ID,
lineTokenConfigured: CONFIG.LINE_MESSAGING_ACCESS_TOKEN !== 'LINE_CHANNEL_ACCESS_TOKEN',
lineTokenPrefix: CONFIG.LINE_MESSAGING_ACCESS_TOKEN.substring(0, 10) + '...',
channelId: CONFIG.LINE_LOGIN_CHANNEL_ID,
channelIdConfigured: CONFIG.LINE_LOGIN_CHANNEL_ID !== 'LINE_LOGIN_CHANNEL_ID',
channelSecret: CONFIG.LINE_LOGIN_CHANNEL_SECRET !== 'LINE_LOGIN_CHANNEL_SECRET',
driveFolderId: CONFIG.DRIVE_FOLDER_ID,
driveFolderConfigured: CONFIG.DRIVE_FOLDER_ID !== 'DRIVE_FOLDER_ID'
};
log('57. 設定状況', config);
// フォルダアクセステスト
if (CONFIG.DRIVE_FOLDER_ID !== 'DRIVE_FOLDER_ID') {
try {
const folder = DriveApp.getFolderById(CONFIG.DRIVE_FOLDER_ID);
log('58. Driveフォルダアクセス: OK', { folderName: folder.getName() });
} catch (error) {
log('59. Driveフォルダアクセス: NG', error.toString(), 'ERROR');
}
} else {
log('60. DRIVE_FOLDER_IDが設定されていません', null, 'WARN');
}
// ログを出力(console.logを直接使用)
console.log('=== 設定確認結果 ===');
debugLogs.forEach(logEntry => {
console.log(`[${logEntry.timestamp}] [${logEntry.level}] ${logEntry.message}`, logEntry.data || '');
});
return {
timestamp: new Date().toISOString(),
configured: config,
logs: debugLogs
};
}
/**
* テスト用関数(ログ強化版)
*/
/**
* doPostをテストするための、より本番に近いテスト関数
*/
function testDoPost() {
// ★★★ 有効なアクセストークンをここに貼り付ける ★★★
const freshAccessToken = 'eyJhbGciOiJIUzI1NiJ9.MRXRtWPTczpo0Iccgbv-qW2Yo_uxvlhIqYqCErrRF6pP_5VASVBt1Qkd-JdIXVHFyQW1DYS6Zh3NuRkbHuAsimmiMs8-dBosIeEshsvfYtKxa7DDm50S_LgGkdQZ7jm-La-BiNGCDUt5ZaQblpjwsi--JZf9mmtO_ks0t6Puk2U.8rmaltJ1Dz-YRLjVXbL4rrltU9WpTomDz5TXDEhBaRM';
// ★★★ テスト用の写真データ(短いサンプル) ★★★
// これは1x1ピクセルの赤い点の画像データです。テストに最適です。
const testPhotoData = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/wcAAwAB/epv2AAAAABJRU5ErkJggg==';
const testEvent = {
postData: {
contents: JSON.stringify({
latitude: 35.681236,
longitude: 139.767125,
type: '写真付きテスト',
details: 'このテストで写真URLが保存されるはずです。',
accessToken: freshAccessToken,
photoData: testPhotoData // ★写真データを追加
})
}
};
log('=== 写真付きテスト実行開始 ===');
// doPostを直接呼び出す
const result = doPost(testEvent);
const resultData = JSON.parse(result.getContent());
log('=== テスト結果 ===', resultData);
// ログが有効な場合は、ログも表示(console.logを直接使用)
if (resultData.logs && resultData.logs.length > 0) {
console.log('=== 詳細ログ ===');
resultData.logs.forEach(logEntry => {
console.log(`[${logEntry.timestamp}] [${logEntry.level}] ${logEntry.message}`, logEntry.data || '');
});
}
}
/**
* 簡単な設定確認(ブラウザで確認可能)
*/
function quickConfigCheck() {
const result = checkConfiguration();
// 結果をわかりやすく表示
let summary = '=== 設定確認結果 ===\n';
summary += `スプレッドシートID: ${result.configured.spreadsheetId}\n`;
summary += `LINEトークン設定: ${result.configured.lineTokenConfigured ? 'OK' : 'NG'}\n`;
summary += `チャネルID設定: ${result.configured.channelIdConfigured ? 'OK' : 'NG'}\n`;
summary += `Driveフォルダ設定: ${result.configured.driveFolderConfigured ? 'OK' : 'NG'}\n`;
// console.logを直接使用
console.log(summary);
return summary;
}
/**************************************************************
* 【一番簡単なデバッグ用】
* doPostの中身をテスト実行し、ログをメールで送信する関数
*/
function testDoPostAndMail() {
// ▼▼▼ ここにあなたのメールアドレスを設定 ▼▼▼
const myEmail = "vacation555@gmail.com";
// フォームから送られてくるデータのダミーを作成
const dummyEvent = {
postData: {
contents: JSON.stringify({
latitude: 35.681236,
longitude: 139.767125,
type: 'テスト通報',
details: 'これはメールデバッグ用のテストです。',
// liff.getAccessToken()で取得した本物のアクセストークンをここに貼り付けると、
// LINE APIのテストもできます。
accessToken: 'eyJhbGciOiJIUzI1NiJ9.MRXRtWPTczpo0Iccgbv-qW2Yo_uxvlhIqYqCErrRF6pP_5VASVBt1Qkd-JdIXVHFyQW1DYS6Zh3NuRkbHuAsimmiMs8-dBosIeEshsvfYtKxa7DDm50S_LgGkdQZ7jm-La-BiNGCDUt5ZaQblpjwsi--JZf9mmtO_ks0t6Puk2U.8rmaltJ1Dz-YRLjVXbL4rrltU9WpTomDz5TXDEhBaRM',
photoData: 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQ...', // ← テスト用のBase64文字列
photoMimeType: 'image/jpeg' // ← MIMEタイプも忘れずに
// photoData: null, // 写真なしでテスト
// photoMimeType: null
})
}
};
let mailBody = "====== GASデバッグログ ======\n\n";
try {
// doPostの処理をここから再現
const e = dummyEvent;
mailBody += "=== POSTリクエスト受信 ===\n";
if (!e.postData || !e.postData.contents) {
throw new Error('POSTデータが見つかりません');
}
const data = JSON.parse(e.postData.contents);
mailBody += "リクエストデータ解析成功\n";
mailBody += "accessTokenの有無: " + (!!data.accessToken) + "\n\n";
// 必須フィールドの検証
if (!data.latitude || !data.longitude || !data.type) {
throw new Error('必須フィールドが不足しています');
}
mailBody += "必須フィールド検証OK\n\n";
// スプレッドシートへの保存はスキップ(またはコメントアウト解除)
// const saveResult = saveToSpreadsheet(data);
// mailBody += "スプレッドシート保存完了\n\n";
const saveResult = { photoUrl: '' }; // ダミーの結果
// LINE投稿の条件チェック
mailBody += "=== LINE投稿チェック開始 ===\n";
const hasAccessToken = data.accessToken && data.accessToken.trim() !== '' && data.accessToken !== 'ここにフロントエンドで取得した本物のアクセストークンを貼り付ける';
const hasMessagingToken = CONFIG.LINE_MESSAGING_ACCESS_TOKEN && CONFIG.LINE_MESSAGING_ACCESS_TOKEN !== 'LINE_CHANNEL_ACCESS_TOKEN';
const hasChannelId = CONFIG.LINE_LOGIN_CHANNEL_ID && CONFIG.LINE_LOGIN_CHANNEL_ID !== 'LINE_LOGIN_CHANNEL_ID';
mailBody += "アクセストークンあり: " + hasAccessToken + "\n";
mailBody += "Messagingトークン設定済み: " + hasMessagingToken + "\n";
mailBody += "チャネルID設定済み: " + hasChannelId + "\n\n";
if (hasAccessToken && hasMessagingToken && hasChannelId) {
mailBody += ">>> LINE投稿の条件を満たしました。API通信を開始します。\n";
try {
const userId = getUserIdFromAccessToken(data.accessToken);
mailBody += "ユーザーID取得成功: " + userId + "\n";
if (userId) {
const lineResult = sendLineMessage(userId, data, saveResult.photoUrl);
mailBody += "LINEプッシュメッセージ送信成功\n";
} else {
mailBody += "ユーザーIDが取得できませんでした。\n";
}
} catch (lineError) {
mailBody += "!!! LINE API通信でエラーが発生 !!!\n";
mailBody += "エラー内容: " + lineError.toString() + "\n";
}
} else {
mailBody += ">>> LINE投稿の条件を満たさなかったため、スキップしました。\n";
}
mailBody += "\n=== 処理正常終了 ===\n";
} catch (error) {
mailBody += "\n!!! 全体処理でエラーが発生 !!!\n";
mailBody += "エラー内容: " + error.toString() + "\n";
}
// 最後に、収集したログをメールで送信
MailApp.sendEmail(myEmail, "GASデバッグレポート", mailBody);
}
LINE公式アカウント等作成
LINEプロバイダー作成
- LINE developeアクセス(上のリンクをクリック)
- 右上の”コンソール”をクリック
- ”作成”をクリック
- ”+新規チャンネル作成”をクリック
- ”LINEログイン”をクリック
- 必要事項を入力
- アプリタイプは”ウェブアプリ”
- ”作成”をクリック
- ”MessagingAPI"をクリック
- ”LINE公式アカウントを作成する”をクリック
- 必要事項を入力し”完了”をクリック
- ”LINE Official Managerへ”をクリック
- ”同意”等をクリックして進める
- ”ホーム画面へ移動”をクリック
- 右上の”設定”をクリック
- ”MessagingAPI”をクリック
- ”MessagingAPIを利用する”をクリック
- 作成したプロバイダーを選択し”同意する”をクリック
- 何も入力せず”OK”をクリック
- ”OK"をクリック
Cloudflare pages 作成(netlify等他のサービスでも良い)
- ”ログイン”をクリック
- ”Workers & Pages”をクリック
- "アプリケーションを作成する"をクリック
- ”Pages”タブをクリック
- どちらか一方の”始める”をクリック(筆者は既存のGitリポジトリをインストールするで進めた)
- 公開対象のリポジトリを選択し”セットアップの開始”をクリック
LINEリッチメニュー作成
- 作成した”MessagingAPI"をクリック(筆者は”作業班dev"と命名)
- ”LINE Official Account Manager"をクリック
- ”ホーム”タブをクリック
- ”リッチメニュー”をクリック
- 作成をクリック
- ”メニュー”の上部エリアをクリック
- 左下の添付レートを選択
- ”選択”をクリック
- ”タイプ”は”リンク”を選択
- webapliをdeployしたCloudflare等のサービスサイトを開く
- webapliを表示する
- deployしたwebpageのURLをコピーする。
- コピーしたURLをペースト
- ”保存”をクリック
変数の設定(Cloudflare)
- ”設定”をクリック
- ”追加”をクリック
- ”テキスト”を選択
- ”変数名”はGAS_WEB_APP_URLとする。
- "AppScript"を開き、"デプロイ"をクリック
- ”デプロイを管理”をクリック
- ”ウェブアプリ”下の”コピー”をクリック
- "Cloudflare"の”値”欄にペースト
- ”追加”をクリック
- ”テキスト”を選択
- ”変数名”はLIFF_IDとする。
- LINE Developers にアクセス
- LINE login の LIFF タブをクリック
- ”LIFFアプリ名”を任意に設定
- ”サイズ”はFull
- CloudflareでdeployしたpageのURLをコピー
- ”エンドポイントURL”欄に貼り付け
- "Scope"は"profile"を選択
- ”友達追加オプション”は”On(normal)"を選択
- ”追加”をクリック
- 作成したLINEログインチャンネルの”LIFF”タブをクリック
- LIFF ID をコピー
- ”追加”をクリック
- ”タイプ”はテキストを選択
- ”変数名”はLIFF_ID
- 先程コピーしたLIFF ID をペースト
変数の設定(GAS)
- GASを開く
- ”settings"アイコン(歯車マーク)をクリック
- ”スクリプトプロパティを編集”をクリック
- ”プロパティ”に DRIVE_FOLDER_ID と入力
- google drive に写真格納用フォルダーを作成
- フォルダーURL ...folders/ 以降をコピー
- ”値”に ペースト
- ”プロパティ”に LINE_CHANNEL_ACCESS_TOKEN と入力
- LINE Messaging API を開く
- Messaging API設定 タブを開く
- チャンネルアクセストークン(長期) をコピーする
- ”値”にペースト
- ”プロパティ”に LINE_LOGIN_CHANNEL_ID と入力
- ”LINEログイン” チャンネルを開く
- ”チャンネル基本設定”タブを開く
- ”チャンネルID”をコピー
- ”値”にペースト
- ”プロパティ”に LINE_LOGIN_CHANNEL_SECRET と入力
- ”チャンネル基本設定”タブを開く
- ”チャンネルシークレット” をコピー
- ”値”にペースト
- ”プロパティ”に LINE_TO_ID と入力
- ”チャンネル基本設定”タブを開く
- ”あなたのユーザーID” をコピー
- ”値”にペースト
- ”プロパティ”に SPREADSHEET_ID と入力
- 作成したGoogle spread sheet を開く
- URLの d/ と /edit との間をコピー
- ”値”にペースト
デプロイ再実行
- GASを再度デプロイ
- Cloudflareの変数を新しいURLに置き換える
- Cloudflareを開く
- ”詳細を表示”をクリック
- ”デプロイを管理”をクリック
- ”デプロイを再試行”をクリック
シートの準備
- Google spread sheet を開く
- シートの名前を 通報データ とする
- 通報データシート1行目は 上図のとおりとする
- ログ シートを追加する
- メール シートを追加する
- メール シートの1行目は図のとおりとする。
- 氏名 メールアドレスを入力する(通報時のメール受信用)
QRコード配布
- LINE MessagingAPIを開く
- QRコード上で右クリックしてメニューを開く
- 画像を保存を選択
- QRコードを配布
Discussion