/** * eBay 新着出品ウォッチャー(Google Apps Script) v1.0.0 * ------------------------------------------------------------ * 指定したeBayセラー(最大5名)の新規出品を、eBay公式API(Browse API)で * 1時間ごとに取得し、このスプレッドシートの「新着一覧」シートへ自動で追加します。 * * ・取得方法:eBay公式 Browse API(item_summary/search)。スクレイピングは行いません。 * ・実行場所:Googleのサーバー(時間主導型トリガー)。パソコンの電源は不要です。 * ・費用  :Google Apps Script / eBay API とも無料枠の範囲で動作します。 * * 使い方:メニュー「eBay新着ツール」の ①〜⑤ を上から順番に実行してください。 * 詳しくは操作マニュアルをご覧ください。 LYCデザイン */ const CONFIG = { VERSION: '1.0.0', MAX_SELLERS: 5, PAGE_LIMIT: 200, // 1回のAPI呼び出しで取得する件数(APIの上限は200) MAX_PAGES_PER_SELLER: 5, // 1セラー・1回の実行で確認する最大ページ数(=最大1,000件) LOOKBACK_HOURS: 6, // 検索反映の遅れ対策:前回の最新出品から何時間さかのぼって再確認するか ID_KEEP_DAYS: 60, // 重複防止用IDを保存しておく日数 LOG_KEEP_ROWS: 1000, // ログシートに残す行数 NOTIFY_INTERVAL_HOURS: 6, // エラー通知メールを送る最短間隔 TIMEZONE: 'Asia/Tokyo', API_BASE: 'https://api.ebay.com', SCOPE: 'https://api.ebay.com/oauth/api_scope', TRIGGER_FUNCTION: 'runCheck', }; const SHEET = { LIST: '新着一覧', SETTINGS: '設定', LOG: 'ログ', IDS: '_取得済ID' }; const LIST_HEADERS = ['出品日時', 'セラー名', '商品名', '商品URL', '価格', '通貨', '区分', '検出日時', '商品ID']; const LOG_HEADERS = ['日時', '種別', '内容']; const ID_HEADERS = ['商品ID', 'セラーID', '登録日時']; // 「設定」シートのレイアウト(行番号) const SET = { SELLER_HEADER_ROW: 4, SELLER_FIRST_ROW: 5, // 5〜9行目 = セラー5名分 SELLER_HEADERS: ['有効', 'セラーID', 'メモ', 'カテゴリID(任意)', '最終チェック', '結果', '今回追加', '累計追加'], OPT_ROW: { MARKETPLACE: 12, INITIAL_COUNT: 13, NOTIFY_EMAIL: 14, AUTORUN: 15, LAST_RUN: 16 }, }; const MARKET_DOMAIN = { EBAY_US: 'www.ebay.com', EBAY_GB: 'www.ebay.co.uk', EBAY_DE: 'www.ebay.de', EBAY_AU: 'www.ebay.com.au', EBAY_CA: 'www.ebay.ca', EBAY_FR: 'www.ebay.fr', EBAY_IT: 'www.ebay.it', EBAY_ES: 'www.ebay.es', }; const PROP = { APP_ID: 'EBAY_APP_ID', CERT_ID: 'EBAY_CERT_ID', LAST_NOTIFY: 'LAST_NOTIFY', LAST_PRUNE: 'LAST_PRUNE' }; let LOG_BUFFER = []; /* ============================================================ * メニュー * ============================================================ */ function onOpen() { SpreadsheetApp.getUi() .createMenu('eBay新着ツール') .addItem('① 初期設定(シートを作成)', 'setupSheets') .addItem('② eBay APIキーを登録', 'registerApiKeys') .addItem('③ 接続テスト', 'testConnection') .addItem('④ 今すぐチェック', 'checkNow') .addItem('⑤ 自動実行をON(1時間ごと)', 'enableAutoRun') .addSeparator() .addItem('稼働状況を確認', 'showStatus') .addItem('自動実行をOFF', 'disableAutoRun') .addToUi(); } /** ① シートを作成(何度実行しても、入力済みの内容は消えません) */ function setupSheets() { const ss = SpreadsheetApp.getActiveSpreadsheet(); setupSheets_(ss); SpreadsheetApp.getUi().alert( '初期設定が完了しました。\n\n' + '次に「設定」シートの B5〜B9 に、監視したいセラーIDを入力してください(最大5名)。\n' + 'そのあと、メニューの「② eBay APIキーを登録」へ進んでください。'); } /** ② eBay の App ID / Cert ID をスクリプトプロパティに保存(シート上には表示しません) */ function registerApiKeys() { const ui = SpreadsheetApp.getUi(); const a = ui.prompt('eBay APIキーの登録(1/2)', 'eBay Developers Program の「Production」キーの App ID(Client ID)を貼り付けてください。', ui.ButtonSet.OK_CANCEL); if (a.getSelectedButton() !== ui.Button.OK) return; const c = ui.prompt('eBay APIキーの登録(2/2)', '同じ画面の Cert ID(Client Secret)を貼り付けてください。', ui.ButtonSet.OK_CANCEL); if (c.getSelectedButton() !== ui.Button.OK) return; const appId = a.getResponseText().trim(); const certId = c.getResponseText().trim(); if (!appId || !certId) { ui.alert('未入力の項目があったため、登録を中止しました。'); return; } const props = PropertiesService.getScriptProperties(); props.setProperty(PROP.APP_ID, appId); props.setProperty(PROP.CERT_ID, certId); CacheService.getScriptCache().remove('ebay_token'); log_('INFO', 'eBay APIキーを登録しました'); flushLog_(SpreadsheetApp.getActiveSpreadsheet()); ui.alert('APIキーを保存しました。\n次に「③ 接続テスト」を実行してください。'); } /** ③ APIキーとセラーIDが正しいかを確認(シートへの書き込みは行いません) */ function testConnection() { const ui = SpreadsheetApp.getUi(); const ss = SpreadsheetApp.getActiveSpreadsheet(); setupSheets_(ss); const settings = readSettings_(ss); const lines = []; try { getToken_(); lines.push('✅ eBay APIへの接続:OK'); } catch (e) { ui.alert('❌ eBay APIに接続できませんでした。\n\n' + e.message + '\n\nAPIキー(App ID / Cert ID)をご確認ください。'); return; } const targets = settings.sellers.filter(s => s.enabled); if (!targets.length) lines.push('⚠ 有効なセラーが登録されていません(設定シート B5〜B9)'); targets.forEach(s => { try { const data = searchItems_(settings.marketplace, s.id, s.categories[0] || '0', 0, 3); const items = data.itemSummaries || []; if (!items.length) { lines.push('⚠ ' + s.id + ':出品が見つかりません(セラーIDの綴りをご確認ください)'); } else { const top = items[0]; lines.push('✅ ' + s.id + ':出品中 ' + Number(data.total || items.length).toLocaleString() + '件/最新 ' + formatJst_(new Date(top.itemCreationDate || top.itemOriginDate)) + '「' + String(top.title).slice(0, 40) + '」'); } } catch (e) { lines.push('❌ ' + s.id + ':' + e.message); } }); ui.alert('接続テストの結果\n\n' + lines.join('\n')); } /** ④ 手動で1回チェック */ function checkNow() { const result = runCheck_(); const ui = SpreadsheetApp.getUi(); if (!result) { ui.alert('別の処理が実行中です。1分ほど待ってから、もう一度お試しください。'); return; } const lines = result.sellers.map(s => '・' + s.id + ':' + s.message); ui.alert('チェックが完了しました(新着 ' + result.added + '件を追加)\n\n' + lines.join('\n')); } /** ⑤ 1時間ごとの自動実行をON */ function enableAutoRun() { removeTriggers_(); ScriptApp.newTrigger(CONFIG.TRIGGER_FUNCTION).timeBased().everyHours(1).create(); const ss = SpreadsheetApp.getActiveSpreadsheet(); setupSheets_(ss); ss.getSheetByName(SHEET.SETTINGS).getRange(SET.OPT_ROW.AUTORUN, 2).setValue('ON(1時間ごと)'); log_('INFO', '自動実行をONにしました(1時間ごと)'); flushLog_(ss); SpreadsheetApp.getUi().alert( '自動実行をONにしました。\n\n' + 'これ以降は、パソコンやこの画面を閉じていても、Googleのサーバー上で1時間ごとにチェックされます。'); } /** 自動実行をOFF */ function disableAutoRun() { const n = removeTriggers_(); const ss = SpreadsheetApp.getActiveSpreadsheet(); setupSheets_(ss); ss.getSheetByName(SHEET.SETTINGS).getRange(SET.OPT_ROW.AUTORUN, 2).setValue('OFF'); log_('INFO', '自動実行をOFFにしました'); flushLog_(ss); SpreadsheetApp.getUi().alert(n ? '自動実行をOFFにしました。' : '自動実行はもともとOFFでした。'); } /** 稼働状況をまとめて表示 */ function showStatus() { const ss = SpreadsheetApp.getActiveSpreadsheet(); setupSheets_(ss); const settings = readSettings_(ss); const props = PropertiesService.getScriptProperties(); const triggers = ScriptApp.getProjectTriggers().filter(t => t.getHandlerFunction() === CONFIG.TRIGGER_FUNCTION); const lastRun = ss.getSheetByName(SHEET.SETTINGS).getRange(SET.OPT_ROW.LAST_RUN, 2).getValue(); const listRows = Math.max(0, ss.getSheetByName(SHEET.LIST).getLastRow() - 1); const lines = [ 'APIキー:' + (props.getProperty(PROP.APP_ID) ? '登録済み' : '未登録'), '自動実行:' + (triggers.length ? 'ON(1時間ごと)' : 'OFF'), '最終実行:' + (lastRun || 'まだ実行されていません'), '新着一覧:' + listRows + '件', '', '監視セラー:', ]; settings.sellers.forEach(s => lines.push('・' + s.id + (s.enabled ? '' : '(停止中)') + ':' + (s.lastResult || '未チェック'))); if (triggers.length && lastRun && isStale_(lastRun)) { lines.push('', '⚠ 最終実行から2時間以上経っています。「ログ」シートを確認してください。'); } SpreadsheetApp.getUi().alert('稼働状況(v' + CONFIG.VERSION + ')\n\n' + lines.join('\n')); } /* ============================================================ * 定期実行の本体(トリガーから呼ばれる) * ============================================================ */ function runCheck() { runCheck_(); } function runCheck_() { const lock = LockService.getScriptLock(); if (!lock.tryLock(10 * 1000)) return null; const ss = SpreadsheetApp.getActiveSpreadsheet(); try { setupSheets_(ss); const settings = readSettings_(ss); const known = loadKnownIds_(ss); const result = { added: 0, sellers: [], errors: [] }; const newRows = []; const newIds = []; const targets = settings.sellers.filter(s => s.enabled); if (!targets.length) log_('WARN', '有効なセラーが登録されていません(設定シート B5〜B9)'); if (targets.length && !hasCredentials_()) { const msg = 'eBay APIキーが未登録です(メニュー「② eBay APIキーを登録」)'; log_('ERROR', msg); result.errors.push(msg); targets.forEach(s => result.sellers.push({ id: s.id, row: s.row, added: 0, message: 'APIキー未登録', error: true })); } else { targets.forEach(seller => { try { const r = checkSeller_(seller, settings, known); r.rows.forEach(row => newRows.push(row)); r.ids.forEach(id => newIds.push([id, seller.id, new Date().toISOString()])); result.added += r.rows.length; result.sellers.push({ id: seller.id, row: seller.row, added: r.rows.length, message: r.message, total: seller.total + r.rows.length }); if (r.warn) log_('WARN', seller.id + ':' + r.warn); } catch (e) { const msg = seller.id + ':' + e.message; log_('ERROR', msg); result.errors.push(msg); result.sellers.push({ id: seller.id, row: seller.row, added: 0, message: 'エラー:' + e.message, error: true }); } }); } // 新着一覧へ書き込み(新しい出品が上に来るよう、2行目に挿入) if (newRows.length) { newRows.sort((a, b) => (a[0] < b[0] ? 1 : a[0] > b[0] ? -1 : 0)); const sh = ss.getSheetByName(SHEET.LIST); sh.insertRowsAfter(1, newRows.length); sh.getRange(2, 1, newRows.length, LIST_HEADERS.length).setValues(newRows); } if (newIds.length) { const idSheet = ss.getSheetByName(SHEET.IDS); idSheet.getRange(idSheet.getLastRow() + 1, 1, newIds.length, ID_HEADERS.length).setValues(newIds); } // 設定シートに各セラーの状況を書き込み const setSheet = ss.getSheetByName(SHEET.SETTINGS); const now = formatJst_(new Date()); result.sellers.forEach(s => { const values = [[now, s.message, s.added]]; setSheet.getRange(s.row, 5, 1, 3).setValues(values); if (!s.error) setSheet.getRange(s.row, 8).setValue(s.total); }); setSheet.getRange(SET.OPT_ROW.LAST_RUN, 2).setValue(now + (result.errors.length ? '(エラーあり)' : '(正常)')); log_(result.errors.length ? 'WARN' : 'INFO', 'チェック完了:新着 ' + result.added + '件を追加' + (result.errors.length ? '/エラー ' + result.errors.length + '件' : '')); pruneKnownIds_(ss); if (result.errors.length) notifyError_(settings, result.errors); return result; } catch (e) { log_('ERROR', '予期しないエラー:' + (e && e.message ? e.message : e)); throw e; } finally { flushLog_(ss); lock.releaseLock(); } } /** * 1セラー分のチェック。 * 新しい順(newlyListed)に取得し、「前回の最新出品 − LOOKBACK_HOURS」より古い出品が出てきたら打ち切ります。 * 重複判定は商品ID(legacyItemId)で行うため、同じ商品が2回登録されることはありません。 */ function checkSeller_(seller, settings, known) { const props = PropertiesService.getScriptProperties(); const wmKey = 'WM_' + settings.marketplace + '_' + seller.id.toLowerCase(); const wmIso = props.getProperty(wmKey); const isFirst = !wmIso; const lookbackMs = CONFIG.LOOKBACK_HOURS * 3600 * 1000; const categories = seller.categories.length ? seller.categories : ['0']; const rows = []; const ids = []; let newest = wmIso ? new Date(wmIso).getTime() : 0; let scanned = 0; let duplicates = 0; let otherSeller = 0; let totalListings = 0; let truncated = false; const detectedAt = formatJst_(new Date()); categories.forEach(cat => { let threshold = isFirst ? null : new Date(wmIso).getTime() - lookbackMs; for (let page = 0; page < CONFIG.MAX_PAGES_PER_SELLER; page++) { const data = searchItems_(settings.marketplace, seller.id, cat, page * CONFIG.PAGE_LIMIT, CONFIG.PAGE_LIMIT); if (page === 0) totalListings += Number(data.total || 0); const items = data.itemSummaries || []; let reachedOld = false; for (let i = 0; i < items.length; i++) { const it = items[i]; const origin = new Date(it.itemOriginDate || it.itemCreationDate).getTime(); if (threshold === null) threshold = origin - lookbackMs; // 初回は1ページ目(最新200件)を必ず記録し、それ以降は最新出品から LOOKBACK_HOURS 分だけ確認 if (origin < threshold && !(isFirst && page === 0)) { reachedOld = true; break; } scanned++; const username = it.seller && it.seller.username ? String(it.seller.username) : ''; if (username && username.toLowerCase() !== seller.id.toLowerCase()) { otherSeller++; continue; } const id = String(it.legacyItemId || String(it.itemId || '').split('|')[1] || it.itemId); if (origin > newest) newest = origin; if (known[id]) { duplicates++; continue; } known[id] = true; ids.push(id); if (isFirst && ids.length > settings.initialCount) continue; // 初回は既存出品をIDだけ記録 rows.push(toRow_(it, id, username || seller.id, settings.marketplace, isFirst ? '初回(既存)' : '新規', detectedAt)); } const end = reachedOld || items.length < CONFIG.PAGE_LIMIT || (data.total !== undefined && (page + 1) * CONFIG.PAGE_LIMIT >= Number(data.total)); if (end) break; if (page === CONFIG.MAX_PAGES_PER_SELLER - 1) truncated = true; } }); if (newest) props.setProperty(wmKey, new Date(newest).toISOString()); let message; let warn = null; if (totalListings === 0) { message = '出品0件(セラーIDをご確認ください)'; warn = '出品が1件も見つかりませんでした。セラーIDの綴り、またはマーケットプレイス設定をご確認ください。'; } else if (isFirst) { message = '初回登録:既存の出品 ' + ids.length + '件を記録(うち最新' + rows.length + '件を一覧に表示)'; } else { message = rows.length ? '新着 ' + rows.length + '件' : '新着なし'; } if (otherSeller > 0 && otherSeller === scanned) { warn = '取得結果がすべて別セラーの商品でした。eBay側の仕様変更の可能性があります。マニュアル「動かなくなったとき」をご確認ください。'; } if (truncated) { warn = '1時間の出品数が多く、最大' + (CONFIG.PAGE_LIMIT * CONFIG.MAX_PAGES_PER_SELLER) + '件の確認上限に達しました。取得漏れの可能性があります。'; } return { rows, ids, message, warn, duplicates }; } function toRow_(it, id, sellerName, marketplace, kind, detectedAt) { const domain = MARKET_DOMAIN[marketplace] || 'www.ebay.com'; const url = it.itemWebUrl ? String(it.itemWebUrl).split('?')[0] : 'https://' + domain + '/itm/' + id; const price = it.price || it.currentBidPrice || {}; return [ formatJst_(new Date(it.itemCreationDate || it.itemOriginDate)), sellerName, String(it.title || ''), url, price.value !== undefined ? Number(price.value) : '', price.currency || '', kind, detectedAt, id, ]; } /* ============================================================ * eBay API * ============================================================ */ function searchItems_(marketplace, sellerId, categoryId, offset, limit) { const filter = 'sellers:{' + sellerId + '},buyingOptions:{FIXED_PRICE|AUCTION}'; const url = CONFIG.API_BASE + '/buy/browse/v1/item_summary/search?' + [ 'category_ids=' + encodeURIComponent(categoryId), 'filter=' + encodeURIComponent(filter), 'sort=newlyListed', 'limit=' + limit, 'offset=' + offset, ].join('&'); return fetchJson_(url, marketplace, 0); } function fetchJson_(url, marketplace, attempt) { const res = UrlFetchApp.fetch(url, { method: 'get', headers: { Authorization: 'Bearer ' + getToken_(), 'X-EBAY-C-MARKETPLACE-ID': marketplace }, muteHttpExceptions: true, }); const code = res.getResponseCode(); const text = res.getContentText(); if (code === 200) return JSON.parse(text); if (code === 401 && attempt === 0) { // トークン期限切れ → 取り直して1回だけ再試行 CacheService.getScriptCache().remove('ebay_token'); return fetchJson_(url, marketplace, attempt + 1); } if (code >= 500 && attempt < 2) { Utilities.sleep(2000 * (attempt + 1)); return fetchJson_(url, marketplace, attempt + 1); } if (code === 429) throw new Error('eBay APIの1日の呼び出し上限に達しました(HTTP 429)。翌日に自動で回復します。'); throw new Error('eBay APIエラー(HTTP ' + code + ')' + describeApiError_(text)); } function describeApiError_(text) { try { const j = JSON.parse(text); const e = (j.errors || [])[0]; if (e) return ' ' + (e.errorId ? '#' + e.errorId + ' ' : '') + (e.longMessage || e.message || ''); } catch (ignore) { /* JSON以外の応答 */ } return ' ' + String(text || '').slice(0, 150); } function getToken_() { const cache = CacheService.getScriptCache(); const cached = cache.get('ebay_token'); if (cached) return cached; const props = PropertiesService.getScriptProperties(); const appId = props.getProperty(PROP.APP_ID); const certId = props.getProperty(PROP.CERT_ID); if (!appId || !certId) throw new Error('eBay APIキーが未登録です(メニュー「② eBay APIキーを登録」)'); const res = UrlFetchApp.fetch(CONFIG.API_BASE + '/identity/v1/oauth2/token', { method: 'post', headers: { Authorization: 'Basic ' + Utilities.base64Encode(appId + ':' + certId) }, contentType: 'application/x-www-form-urlencoded', payload: 'grant_type=client_credentials&scope=' + encodeURIComponent(CONFIG.SCOPE), muteHttpExceptions: true, }); if (res.getResponseCode() !== 200) { throw new Error('アクセストークンを取得できませんでした(HTTP ' + res.getResponseCode() + ')' + describeApiError_(res.getContentText())); } const j = JSON.parse(res.getContentText()); const ttl = Math.min(21600, Math.max(60, Number(j.expires_in || 7200) - 300)); cache.put('ebay_token', j.access_token, ttl); return j.access_token; } function hasCredentials_() { const props = PropertiesService.getScriptProperties(); return !!(props.getProperty(PROP.APP_ID) && props.getProperty(PROP.CERT_ID)); } /* ============================================================ * シート関連 * ============================================================ */ function setupSheets_(ss) { let list = ss.getSheetByName(SHEET.LIST); if (!list) { list = ss.insertSheet(SHEET.LIST, 0); list.getRange(1, 1, 1, LIST_HEADERS.length).setValues([LIST_HEADERS]) .setFontWeight('bold').setBackground('#1f3a5f').setFontColor('#ffffff'); list.setFrozenRows(1); [140, 150, 420, 260, 80, 60, 90, 140, 120].forEach((w, i) => list.setColumnWidth(i + 1, w)); } let set = ss.getSheetByName(SHEET.SETTINGS); if (!set) { set = ss.insertSheet(SHEET.SETTINGS); set.getRange(1, 1).setValue('eBay新着ツール 設定').setFontWeight('bold').setFontSize(14); set.getRange(3, 1).setValue('■ 監視するセラー(最大5名) ※B列にセラーIDを入力。A列のチェックを外すと一時停止').setFontWeight('bold'); set.getRange(SET.SELLER_HEADER_ROW, 1, 1, SET.SELLER_HEADERS.length).setValues([SET.SELLER_HEADERS]) .setFontWeight('bold').setBackground('#1f3a5f').setFontColor('#ffffff'); const blank = []; for (let i = 0; i < CONFIG.MAX_SELLERS; i++) blank.push([true, '', i < 2 ? 'ライバル' + (i + 1) : '', '', '', '', '', 0]); set.getRange(SET.SELLER_FIRST_ROW, 1, CONFIG.MAX_SELLERS, SET.SELLER_HEADERS.length).setValues(blank); set.getRange(SET.SELLER_FIRST_ROW, 1, CONFIG.MAX_SELLERS, 1).insertCheckboxes(); set.getRange(SET.SELLER_FIRST_ROW, 2, CONFIG.MAX_SELLERS, 1).setBackground('#fff8dc'); set.getRange(11, 1).setValue('■ 共通設定').setFontWeight('bold'); set.getRange(SET.OPT_ROW.MARKETPLACE, 1, 5, 3).setValues([ ['マーケットプレイス', 'EBAY_US', 'eBay.com=EBAY_US(通常は変更不要)'], ['初回に一覧へ載せる件数', 20, '新しくセラーを追加したとき、既存出品のうち最新何件を一覧に表示するか'], ['エラー通知メール', '', '空欄=このシートの所有者へ送信/「OFF」で通知しない'], ['自動実行', 'OFF', '※メニュー⑤でONになります(ここは表示用)'], ['最終実行', '', '※自動で更新されます'], ]); [180, 200, 110, 140, 140, 320, 80, 80].forEach((w, i) => set.setColumnWidth(i + 1, w)); } let log = ss.getSheetByName(SHEET.LOG); if (!log) { log = ss.insertSheet(SHEET.LOG); log.getRange(1, 1, 1, LOG_HEADERS.length).setValues([LOG_HEADERS]) .setFontWeight('bold').setBackground('#1f3a5f').setFontColor('#ffffff'); log.setFrozenRows(1); [140, 70, 640].forEach((w, i) => log.setColumnWidth(i + 1, w)); } let ids = ss.getSheetByName(SHEET.IDS); if (!ids) { ids = ss.insertSheet(SHEET.IDS); ids.getRange(1, 1, 1, ID_HEADERS.length).setValues([ID_HEADERS]).setFontWeight('bold'); ids.hideSheet(); } } function readSettings_(ss) { const sh = ss.getSheetByName(SHEET.SETTINGS); const rows = sh.getRange(SET.SELLER_FIRST_ROW, 1, CONFIG.MAX_SELLERS, SET.SELLER_HEADERS.length).getValues(); const sellers = []; rows.forEach((r, i) => { const id = normalizeSellerId_(r[1]); if (!id) return; sellers.push({ row: SET.SELLER_FIRST_ROW + i, enabled: r[0] === true || String(r[0]).toUpperCase() === 'TRUE', id: id, categories: String(r[3] || '').split(/[\s,、]+/).filter(Boolean), lastResult: r[5], total: Number(r[7]) || 0, }); }); const opt = row => sh.getRange(row, 2).getValue(); const initial = Number(opt(SET.OPT_ROW.INITIAL_COUNT)); return { sellers: sellers, marketplace: String(opt(SET.OPT_ROW.MARKETPLACE) || 'EBAY_US').trim().toUpperCase(), initialCount: isNaN(initial) ? 20 : Math.max(0, initial), notifyEmail: String(opt(SET.OPT_ROW.NOTIFY_EMAIL) || '').trim(), }; } /** セラーページのURLが貼られても、セラーIDだけを取り出す */ function normalizeSellerId_(value) { let s = String(value || '').trim(); if (!s) return ''; let m = s.match(/[?&]_ssn=([^&#]+)/); if (m) return decodeURIComponent(m[1]); m = s.match(/ebay\.[a-z.]+\/usr\/([^/?#]+)/i); if (m) return decodeURIComponent(m[1]); return s.replace(/\s+/g, ''); } function loadKnownIds_(ss) { const sh = ss.getSheetByName(SHEET.IDS); const known = {}; const last = sh.getLastRow(); if (last < 2) return known; sh.getRange(2, 1, last - 1, 1).getValues().forEach(r => { if (r[0] !== '') known[String(r[0])] = true; }); return known; } /** 重複防止用IDのうち古いものを1日1回削除(シートが重くならないように) */ function pruneKnownIds_(ss) { const props = PropertiesService.getScriptProperties(); const lastPrune = Number(props.getProperty(PROP.LAST_PRUNE) || 0); const nowMs = new Date().getTime(); if (nowMs - lastPrune < 24 * 3600 * 1000) return; props.setProperty(PROP.LAST_PRUNE, String(nowMs)); const sh = ss.getSheetByName(SHEET.IDS); const last = sh.getLastRow(); if (last < 2) return; const limit = nowMs - CONFIG.ID_KEEP_DAYS * 24 * 3600 * 1000; const values = sh.getRange(2, 1, last - 1, ID_HEADERS.length).getValues(); const keep = values.filter(r => new Date(r[2]).getTime() >= limit); if (keep.length === values.length) return; sh.getRange(2, 1, last - 1, ID_HEADERS.length).clearContent(); if (keep.length) sh.getRange(2, 1, keep.length, ID_HEADERS.length).setValues(keep); } function removeTriggers_() { let n = 0; ScriptApp.getProjectTriggers().forEach(t => { if (t.getHandlerFunction() === CONFIG.TRIGGER_FUNCTION) { ScriptApp.deleteTrigger(t); n++; } }); return n; } /* ============================================================ * ログ・通知・ユーティリティ * ============================================================ */ function log_(level, message) { LOG_BUFFER.push([formatJst_(new Date()), level, message]); } function flushLog_(ss) { if (!LOG_BUFFER.length) return; const rows = LOG_BUFFER.slice().reverse(); // 新しいログが上 LOG_BUFFER = []; const sh = ss.getSheetByName(SHEET.LOG); if (!sh) return; sh.insertRowsAfter(1, rows.length); sh.getRange(2, 1, rows.length, LOG_HEADERS.length).setValues(rows); const over = sh.getLastRow() - 1 - CONFIG.LOG_KEEP_ROWS; if (over > 0) sh.deleteRows(CONFIG.LOG_KEEP_ROWS + 2, over); } function notifyError_(settings, errors) { if (settings.notifyEmail.toUpperCase() === 'OFF') return; const props = PropertiesService.getScriptProperties(); const nowMs = new Date().getTime(); const last = Number(props.getProperty(PROP.LAST_NOTIFY) || 0); if (nowMs - last < CONFIG.NOTIFY_INTERVAL_HOURS * 3600 * 1000) return; const to = settings.notifyEmail || Session.getEffectiveUser().getEmail(); if (!to) return; props.setProperty(PROP.LAST_NOTIFY, String(nowMs)); MailApp.sendEmail(to, '【eBay新着ツール】エラーが発生しました', 'eBay新着ツールの自動チェックでエラーが発生しました。\n\n' + errors.map(e => '・' + e).join('\n') + '\n\nスプレッドシートの「ログ」シートと、操作マニュアルの「エラーが出たとき」をご確認ください。\n' + SpreadsheetApp.getActiveSpreadsheet().getUrl() + '\n\n※同じ通知は' + CONFIG.NOTIFY_INTERVAL_HOURS + '時間に1回までに抑えています。'); } function formatJst_(date) { return Utilities.formatDate(date, CONFIG.TIMEZONE, 'yyyy/MM/dd HH:mm'); } function isStale_(lastRunText) { const m = String(lastRunText).match(/(\d{4})\/(\d{2})\/(\d{2}) (\d{2}):(\d{2})/); if (!m) return false; const jstMs = Date.UTC(+m[1], +m[2] - 1, +m[3], +m[4] - 9, +m[5]); return new Date().getTime() - jstMs > 2 * 3600 * 1000; }