
世界各地の世界遺産を、美しい写真とともに楽しめるScriptableウィジェットです。
UNESCO(国際連合教育科学文化機関)の世界遺産データをもとに、世界遺産を1日1ヶ所ランダムに表示します。
ウィジェットには余計な情報を詰め込まず、世界遺産の写真を大きく表示。
画面下には「France 2025年登録」のように、国名と世界遺産への登録年だけをシンプルに表示します。
小・中・大のウィジェットサイズに対応しており、同じ世界遺産に複数の写真がある場合は、それぞれのサイズに適した写真を自動で選択します。
ウィジェットをタップするとUNESCO公式ページが開くので、気になった場所がどんな世界遺産なのか詳しく調べることもできます。
ウィジェットの表示内容
UNESCOの世界遺産データから、その日に表示する遺産を選びます。
サイズごとの表示
- 世界遺産の写真・名称・国・登録年などは、小・中・大で共通です。
- 情報が増えるのではなく、写真の大きさ・トリミング・文字の配置が変わります。
設定手順
上から順に進めてください。コードの保存と設定のコピーに必要な操作欄は、それぞれの手順の中にあります。
- Scriptableを用意する
App StoreでScriptableを開き、iPhoneにインストールします。すでに入っている場合は次へ進んでください。
- コードをコピーして保存する
下のコード欄の「コピー」を押します。Scriptableへ移動し、「+」から新しいスクリプトを作ってコード全文を貼り付け、今日の世界遺産などの名前で保存してください。
保存したらこのページに戻り、次の手順へ進みます。
// Variables used by Scriptable. // These must be at the very top of the file. Do not edit. // icon-color: blue; icon-glyph: globe; // Version: 1.0.1 // ===================================================== // 世界遺産フォト - Scriptable Widget V5 // データ:UNESCO World Heritage // ===================================================== // ===================================================== // 設定 // ===================================================== const CONFIG = { API: "https://data.unesco.org/api/explore/v2.1/catalog/datasets/whc001/records", CACHE_FOLDER: "world_heritage_photo_v5", TIMEOUT: 15, // 調査するDocument数 MAX_DOCUMENTS: 4, // 1 Documentあたりの画像候補数 MAX_CANDIDATES: 6, // 画像ありレコード探索件数 BATCH_SIZE: 20, // 画像探索の制限時間 TIME_BUDGET_MS: 20000 }; // ===================================================== // キャッシュ画像サイズ // ===================================================== const OUTPUT_SIZE = { small: { w: 800, h: 800 }, medium: { w: 1600, h: 800 }, large: { w: 1200, h: 1200 } }; // ===================================================== // 実行開始時間 // ===================================================== const START_TIME = Date.now(); function timeUp() { return ( Date.now() - START_TIME > CONFIG.TIME_BUDGET_MS ); } // ===================================================== // Widgetサイズ // ===================================================== const FAMILY = config.widgetFamily || "medium"; // ===================================================== // 目標アスペクト比 // ===================================================== const TARGET_RATIO = { small: 1.0, medium: 2.0, large: 1.0 }; const targetRatio = TARGET_RATIO[FAMILY] || 2.0; // ===================================================== // FileManager // ===================================================== const fm = FileManager.local(); const cacheDir = fm.joinPath( fm.documentsDirectory(), CONFIG.CACHE_FOLDER ); if (!fm.fileExists(cacheDir)) { fm.createDirectory( cacheDir, true ); } // ===================================================== // 今日の日付 // ===================================================== const now = new Date(); const dateKey = [ now.getFullYear(), String( now.getMonth() + 1 ).padStart(2, "0"), String( now.getDate() ).padStart(2, "0") ].join("-"); // ===================================================== // サイズ別キャッシュ // ===================================================== const jsonPath = fm.joinPath( cacheDir, `heritage_${FAMILY}.json` ); const imagePath = fm.joinPath( cacheDir, `heritage_${FAMILY}.jpg` ); // ===================================================== // メイン // ===================================================== let data = null; let image = null; let errorMessage = ""; try { const cache = loadTodayCache(); // =================================================== // 今日のキャッシュ // =================================================== if ( cache && cache.image ) { data = cache.data; image = cache.image; console.log( `今日のキャッシュを使用:${FAMILY}` ); console.log( `キャッシュ画像:${image.size.width} × ${image.size.height}` ); } else { // ================================================= // 今日の世界遺産 // ================================================= const raw = await getTodayRecord(); data = normalizeRecord(raw); console.log( "============================" ); console.log( `世界遺産:${data.name}` ); console.log( `国:${data.country}` ); console.log( `登録年:${data.year}` ); console.log( `Widget:${FAMILY}` ); console.log( `目標比率:${targetRatio}` ); console.log( "============================" ); // ================================================= // 最適画像 // ================================================= const result = await findBestImage( data ); if ( result && result.image ) { image = result.image; console.log( "============================" ); console.log( "採用画像" ); console.log( `元サイズ:${result.width} × ${result.height}` ); console.log( `元比率:${result.ratio.toFixed(2)}` ); console.log( `キャッシュ画像:${image.size.width} × ${image.size.height}` ); console.log( `URL:${result.url}` ); console.log( "============================" ); } else { throw new Error( "画像を取得できませんでした" ); } saveCache( data, image ); } } catch (e) { errorMessage = String(e); console.log( "エラー:" + errorMessage ); // =================================================== // 前回キャッシュ // =================================================== const old = loadAnyCache(); if ( old && old.image ) { data = old.data; image = old.image; } } // ===================================================== // Widget作成 // ===================================================== let widget; if ( data && image ) { widget = createWidget( data, image ); } else { widget = createErrorWidget( errorMessage ); } // ===================================================== // 表示 // ===================================================== Script.setWidget( widget ); if (!config.runsInWidget) { if ( FAMILY === "small" ) { await widget.presentSmall(); } else if ( FAMILY === "large" ) { await widget.presentLarge(); } else { await widget.presentMedium(); } } Script.complete(); // ===================================================== // 今日の世界遺産を取得 // ===================================================== async function getTodayRecord() { // =================================================== // 総件数 // =================================================== const countReq = new Request( CONFIG.API + "?limit=1" ); countReq.timeoutInterval = CONFIG.TIMEOUT; const info = await countReq.loadJSON(); if ( !info || !info.results || !info.results.length ) { throw new Error( "UNESCOデータを取得できません" ); } let total = Number( info.total_count || 0 ); if (!total) { total = 1200; } // =================================================== // 日付固定シード // =================================================== const seed = Math.abs( hashString( dateKey ) ); const maxOffset = Math.max( total - CONFIG.BATCH_SIZE, 0 ); const offset = seed % (maxOffset + 1); // =================================================== // 20件まとめて取得 // =================================================== const req = new Request( CONFIG.API + `?limit=${CONFIG.BATCH_SIZE}` + `&offset=${offset}` ); req.timeoutInterval = CONFIG.TIMEOUT; const json = await req.loadJSON(); const results = ( json && json.results ) || []; // =================================================== // 画像付きだけ残す // =================================================== const withImage = results.filter( item => item.main_image_url || extractURLs( item.images_urls ).length ); if ( !withImage.length ) { throw new Error( "画像付きの世界遺産が見つかりません" ); } // =================================================== // 同じ日は同じ世界遺産 // =================================================== return withImage[ seed % withImage.length ]; } // ===================================================== // データ整理 // ===================================================== function normalizeRecord( item ) { const name = item.name_en || item.name_fr || item.name || ""; // =================================================== // 国名 // =================================================== let country = item.states_names || ""; if ( Array.isArray(country) ) { country = country.join(" / "); } // =================================================== // 登録年 // =================================================== let year = item.date_inscribed || item.date_inscription || item.year || ""; const yearMatch = String(year).match( /\d{4}/ ); year = yearMatch ? yearMatch[0] : ""; // =================================================== // 画像 // =================================================== const mainImageURL = item.main_image_url || ""; const imageURLs = extractURLs( item.images_urls ); // =================================================== // ID // =================================================== const id = item.id_no || ""; const pageURL = id ? `https://whc.unesco.org/en/list/${id}` : "https://whc.unesco.org/en/list/"; return { id, name, country, year, mainImageURL, imageURLs, pageURL }; } // ===================================================== // URL抽出 // ===================================================== function extractURLs( value ) { if (!value) { return []; } if ( Array.isArray(value) ) { return value .map(String) .filter(Boolean); } const matches = String(value).match( /https?:\/\/[^,\s"'<>]+/g ); return matches || []; } // ===================================================== // 最適画像を取得 // ===================================================== async function findBestImage( data ) { // =================================================== // Document ID // =================================================== const documentIDs = []; for ( const url of [ data.mainImageURL, ...data.imageURLs ] ) { const id = getDocumentID( url ); if ( id && !documentIDs.includes(id) ) { documentIDs.push(id); } if ( documentIDs.length >= CONFIG.MAX_DOCUMENTS ) { break; } } console.log( `Document候補:${documentIDs.length}件` ); // =================================================== // 高画質候補 // =================================================== const candidates = []; for ( const id of documentIDs ) { if (timeUp()) { console.log( "制限時間のため探索を打ち切り" ); break; } try { const result = await getOriginalImageFromDocument( id ); if (!result) { continue; } result.ratio = result.width / result.height; const ratioDiff = Math.abs( Math.log( result.ratio / targetRatio ) ); const longSide = Math.max( result.width, result.height ); result.score = ratioDiff - Math.min( longSide, 6000 ) / 100000; console.log( `Document ${id}:${result.width}×${result.height}` + ` ratio=${result.ratio.toFixed(2)}` + ` score=${result.score.toFixed(3)}` ); candidates.push( result ); // ================================================= // 十分良ければ終了 // ================================================= if ( ratioDiff < 0.25 && longSide >= 2000 ) { console.log( "十分な候補が見つかったので探索終了" ); break; } } catch (e) { console.log( `Document ${id}:取得失敗` ); } } // =================================================== // 最良候補 // =================================================== if ( candidates.length ) { candidates.sort( (a, b) => a.score - b.score ); return candidates[0]; } // =================================================== // フォールバック // =================================================== console.log( "Documentから取得できないためフォールバック" ); const fallbackURLs = [ data.mainImageURL, ...data.imageURLs.filter( url => !getDocumentID(url) ) ].filter(Boolean); for ( const url of fallbackURLs ) { if (timeUp()) { break; } try { const original = await loadImage( url ); if (!original) { continue; } const width = original.size.width; const height = original.size.height; if ( width <= 32 || height <= 32 ) { continue; } return { image: resizeCover( original ), url, width, height, ratio: width / height, score: 999 }; } catch (e) { // 次へ } } return null; } // ===================================================== // DocumentからOriginal Size取得 // ===================================================== async function getOriginalImageFromDocument( id ) { const pageURL = `https://whc.unesco.org/en/documents/${id}`; console.log( `確認:${pageURL}` ); const req = new Request( pageURL ); req.timeoutInterval = CONFIG.TIMEOUT; const html = decodeHTML( await req.loadString() ); if (!html) { return null; } // =================================================== // 画像候補 // =================================================== const candidates = extractImageCandidates( html ); if ( !candidates.length ) { return null; } candidates.sort( (a, b) => imageURLScore(b) - imageURLScore(a) ); let best = null; const max = Math.min( candidates.length, CONFIG.MAX_CANDIDATES ); // =================================================== // 実画像を確認 // =================================================== for ( let i = 0; i < max; i++ ) { if (timeUp()) { break; } const url = absoluteURL( candidates[i] ); try { const original = await loadImage( url ); if (!original) { continue; } const width = original.size.width; const height = original.size.height; if ( width <= 32 || height <= 32 ) { continue; } const pixels = width * height; console.log( `候補:${width} × ${height}` ); // ================================================= // 現在の最大画像 // ================================================= if ( !best || pixels > best.pixels ) { best = { // ここでWidget用サイズへ縮小して保持 image: resizeCover( original ), url, width, height, pixels }; } // ================================================= // 3000px以上なら十分 // ================================================= if ( Math.max( width, height ) >= 3000 ) { console.log( "Original Size級を取得" ); break; } } catch (e) { // 次へ } } return best; } // ===================================================== // 縮小 + 中央クロップ // // cover方式 // 拡大はしない // ===================================================== function resizeCover( image ) { const target = OUTPUT_SIZE[FAMILY] || OUTPUT_SIZE.medium; const iw = image.size.width; const ih = image.size.height; const scale = Math.max( target.w / iw, target.h / ih ); // =================================================== // 元画像が目標より小さい場合 // =================================================== if ( scale >= 1 ) { return image; } const dw = iw * scale; const dh = ih * scale; const ctx = new DrawContext(); ctx.size = new Size( target.w, target.h ); // Retina倍率を掛けない ctx.respectScreenScale = false; ctx.opaque = true; ctx.drawImageInRect( image, new Rect( ( target.w - dw ) / 2, ( target.h - dh ) / 2, dw, dh ) ); return ctx.getImage(); } // ===================================================== // HTML内画像URL候補 // ===================================================== function extractImageCandidates( html ) { const urls = new Set(); let match; // =================================================== // href // =================================================== const hrefRegex = /href\s*=\s*["']([^"']+)["']/gi; while ( ( match = hrefRegex.exec(html) ) !== null ) { const url = match[1]; if ( looksLikeImage( url ) ) { urls.add(url); } } // =================================================== // src // =================================================== const srcRegex = /src\s*=\s*["']([^"']+)["']/gi; while ( ( match = srcRegex.exec(html) ) !== null ) { const url = match[1]; if ( looksLikeImage( url ) ) { urls.add(url); } } // =================================================== // HTML / JavaScript内URL // =================================================== const rawRegex = /https?:\/\/[^"'<>\\\s]+/gi; while ( ( match = rawRegex.exec(html) ) !== null ) { const url = match[0]; if ( looksLikeImage( url ) ) { urls.add(url); } } return Array.from( urls ); } // ===================================================== // Document ID // ===================================================== function getDocumentID( url ) { if (!url) { return null; } const text = String(url); let match = text.match( /\/documents?\/(\d+)/i ); if (match) { return match[1]; } match = text.match( /[?&]id=(\d+)/i ); if (match) { return match[1]; } return null; } // ===================================================== // 画像URL判定 // ===================================================== function looksLikeImage( url ) { const text = String(url) .toLowerCase(); return ( /\.(jpg|jpeg|png|webp)(\?|&|$)/i.test( text ) || text.includes( "tool_image" ) || text.includes( "image_download" ) || text.includes( "download" ) || text.includes( "original" ) ); } // ===================================================== // Original候補優先順位 // ===================================================== function imageURLScore( url ) { const text = String(url) .toLowerCase(); let score = 0; if ( text.includes( "original" ) ) { score += 1000; } if ( text.includes( "download" ) ) { score += 800; } if ( text.includes( "full" ) ) { score += 600; } if ( text.includes( "large" ) ) { score += 400; } if ( text.includes( "tool_image" ) ) { score += 200; } if ( text.includes( "thumb" ) ) { score -= 1000; } if ( text.includes( "thumbnail" ) ) { score -= 1000; } return score; } // ===================================================== // HTML Decode // ===================================================== function decodeHTML( html ) { return String(html) .replace( /&/g, "&" ) .replace( /&/g, "&" ) .replace( /"/g, '"' ) .replace( /'/g, "'" ) .replace( /</g, "<" ) .replace( />/g, ">" ); } // ===================================================== // 絶対URL // ===================================================== function absoluteURL( url ) { const text = String(url); if ( /^https?:\/\//i.test( text ) ) { return text; } if ( text.startsWith("//") ) { return ( "https:" + text ); } if ( text.startsWith("/") ) { return ( "https://whc.unesco.org" + text ); } return ( "https://whc.unesco.org/" + text ); } // ===================================================== // 画像取得 // ===================================================== async function loadImage( url ) { const req = new Request( url ); req.timeoutInterval = CONFIG.TIMEOUT; return await req.loadImage(); } // ===================================================== // Widget // ===================================================== function createWidget( data, image ) { const w = new ListWidget(); w.setPadding( 0, 0, 0, 0 ); // =================================================== // タップ // =================================================== w.url = data.pageURL; // =================================================== // 背景画像 // =================================================== w.backgroundImage = image; // =================================================== // レイアウト // =================================================== const main = w.addStack(); main.layoutVertically(); main.addSpacer(); // =================================================== // 下部テキスト // =================================================== const bottom = main.addStack(); let bottomPadding = 10; let sidePadding = 12; let fontSize = 10; if ( FAMILY === "small" ) { bottomPadding = 9; sidePadding = 10; fontSize = 9; } else if ( FAMILY === "large" ) { bottomPadding = 12; sidePadding = 14; fontSize = 11; } bottom.setPadding( 0, sidePadding, bottomPadding, sidePadding ); bottom.addSpacer(); const parts = []; if ( data.country ) { parts.push( data.country ); } if ( data.year ) { parts.push( `${data.year}年登録` ); } const text = bottom.addText( parts.join(" ") ); text.font = Font.semiboldSystemFont( fontSize ); text.textColor = Color.white(); text.textOpacity = 0.96; text.lineLimit = 1; text.minimumScaleFactor = 0.6; // =================================================== // 黒帯なし・文字影のみ // =================================================== text.shadowColor = new Color( "#000000", 0.95 ); text.shadowRadius = 2; text.shadowOffset = new Point( 0, 1 ); bottom.addSpacer(); return w; } // ===================================================== // 今日のキャッシュ // ===================================================== function loadTodayCache() { try { if ( !fm.fileExists( jsonPath ) || !fm.fileExists( imagePath ) ) { return null; } const json = JSON.parse( fm.readString( jsonPath ) ); if ( json.date !== dateKey ) { return null; } const image = fm.readImage( imagePath ); if (!image) { return null; } return { data: json.data, image }; } catch (e) { return null; } } // ===================================================== // 前回キャッシュ // ===================================================== function loadAnyCache() { try { if ( !fm.fileExists( jsonPath ) || !fm.fileExists( imagePath ) ) { return null; } const json = JSON.parse( fm.readString( jsonPath ) ); const image = fm.readImage( imagePath ); if (!image) { return null; } return { data: json.data, image }; } catch (e) { return null; } } // ===================================================== // キャッシュ保存 // ===================================================== function saveCache( data, image ) { try { fm.writeString( jsonPath, JSON.stringify({ date: dateKey, data: data }) ); if (image) { fm.writeImage( imagePath, image ); } console.log( `キャッシュ保存:${FAMILY}` ); } catch (e) { console.log( "キャッシュ保存失敗:" + e ); } } // ===================================================== // 日付ハッシュ // ===================================================== function hashString( str ) { let hash = 0; for ( let i = 0; i < str.length; i++ ) { hash = ( (hash << 5) - hash ) + str.charCodeAt(i); hash |= 0; } return hash; } // ===================================================== // エラーWidget // ===================================================== function createErrorWidget( message ) { const w = new ListWidget(); w.backgroundColor = new Color( "#222222" ); w.setPadding( 14, 14, 14, 14 ); w.addSpacer(); const text = w.addText( "World Heritage" ); text.font = Font.semiboldSystemFont( 11 ); text.textColor = Color.white(); text.centerAlignText(); if (message) { w.addSpacer(5); const detail = w.addText( "画像を取得できませんでした" ); detail.font = Font.systemFont( 8 ); detail.textColor = new Color( "#aaaaaa" ); detail.centerAlignText(); } w.addSpacer(); return w; } - ホーム画面に追加して設定する
iPhoneのホーム画面を長押しし、ウィジェット追加画面からScriptableを選びます。この記事の対応サイズの説明に合わせてサイズを選んで追加してください。
追加したウィジェットを長押しして「ウィジェットを編集」を開き、「Script」に保存したスクリプトを選びます。
「Parameter」は空欄で構いません。
編集画面を閉じて表示を確認します。ホーム画面への反映には少し時間がかかる場合があります。
動作確認・使用結果
| 確認した端末 | iPhone 17 ProMAX |
|---|---|
| iOS | iOS27 |
| Scriptable | 1.7.19 |
| 確認したサイズ | 小・中・大 |
| バージョン | 1.0.1 |
| 動作確認日 | 2026-09-29 |
表示・使用時のポイント
UNESCOの世界遺産データから、その日に表示する遺産を選びます。同じ日は同じ遺産を眺められ、画像を取得できない場合は代替表示や保存画像を使います。
更新履歴
1.0.1
- 動作改善 全体的な動作安定性を改善しました。
1.0.0
- 初回リリース
よくあるエラーと対処
- 確認すること
- 表示されない場合は、ウィジェット編集画面の「Script」に保存したスクリプトが選ばれているかも確認してください。コードを貼り直すときは全文を置き換えます。
- 同じ世界遺産が表示され続ける
- 同じ日の表示を固定する仕様です。翌日の更新を確認してください。
- 画像だけ出ない
- 写真の公開状況や通信状態によります。再実行しても出ない場合は配信元の状況を確認してください。
注意事項・制限
- インターネット接続が必要です。一度取得した画像はキャッシュされるため、同じ日の表示では通信量を抑えられます。
世界遺産によって公開されている写真の枚数・サイズ・縦横比が異なるため、写真の構図や画質には多少の違いがあります。また、UNESCO側の仕様変更などにより、一時的に画像やデータを取得できなくなる場合があります。 - 写真は現地のライブ映像ではありません。
- データや写真の利用条件はUNESCOや各掲載元の表記に従います。
- ホーム画面の更新タイミングはiOSが管理するため、操作やデータの変更がすぐに反映されない場合があります。
データ提供・参考資料
UNESCO Datahub:世界遺産の登録データ。
UNESCO World Heritage Centre:世界遺産の情報・写真。
SHARE

