PageSourceSearch

https://www.ibaraki-airport.net/ai-search/widget.js

js ibaraki-airport.net collected 2026-10-02 06:52:49 UTC 23,565 bytes, 658 lines download raw bytes

1/**
2 * 茨城空港 AIサイト内検索 ウィジェット
3 *
4 * 設置方法:
5 *   <div id="ai-search"></div>
6 *   <script src="/ai-search/widget.js" defer></script>
7 *
8 * 表示言語と検索対象言語は、現在のURLのパスから自動判定します。
9 *   /en/...    → 英語のページのみを検索し、英語で回答
10 *   /ko/...    → 韓国語のページのみ
11 *   /zh-CN/... → 簡体字のページのみ
12 *   /zh-TW/... → 繁体字のページのみ
13 *   それ以外   → 日本語のページのみ
14 *
15 * スタイルはShadow DOM内に閉じているため、WordPressテーマのCSSと干渉しません。
16 */
17(function () {
18  'use strict';
19
20  var script = document.currentScript;
21
22  var ENDPOINT = (script && script.getAttribute('data-endpoint')) || '/ai-search/api.php';
23  var MOUNT_ID = (script && script.getAttribute('data-mount')) || 'ai-search';
24
25  // --------------------------------------------------------------------
26  // 言語
27  // --------------------------------------------------------------------
28
29  var I18N = {
30    'ja': {
31      placeholder: '知りたいことを入力してください(例:駐車場の料金)',
32      button: '検索',
33      loading: '回答を作成しています…',
34      sources: '参照したページ',
35      newTab: '新しいタブで開きます',
36      external: '外部サイト',
37      note: 'この回答はAIがサイト内の情報をもとに生成しています。最新の情報は参照ページをご確認ください。',
38      error: '検索中にエラーが発生しました。しばらくしてからお試しください。',
39      empty: '検索したい内容を入力してください。'
40    },
41    'en': {
42      placeholder: 'Ask anything about Ibaraki Airport (e.g. parking fees)',
43      button: 'Search',
44      loading: 'Preparing an answer…',
45      sources: 'Referenced pages',
46      newTab: 'opens in a new tab',
47      external: 'external site',
48      note: 'This answer is generated by AI based on information on this site. Please check the referenced pages for the latest details.',
49      error: 'An error occurred during the search. Please try again later.',
50      empty: 'Please enter what you would like to search for.'
51    },
52    'ko': {
53      placeholder: '궁금한 점을 입력해 주세요 (예: 주차 요금)',
54      button: '검색',
55      loading: '답변을 작성하고 있습니다…',
56      sources: '참조한 페이지',
57      newTab: '새 탭에서 열립니다',
58      external: '외부 사이트',
59      note: '이 답변은 사이트 내 정보를 바탕으로 AI가 생성했습니다. 최신 정보는 참조 페이지를 확인해 주세요.',
60      error: '검색 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.',
61      empty: '검색할 내용을 입력해 주세요.'
62    },
63    'zh-CN': {
64      placeholder: '请输入您想了解的内容(例如:停车费)',
65      button: '搜索',
66      loading: '正在生成回答…',
67      sources: '参考页面',
68      newTab: '在新标签页中打开',
69      external: '外部网站',
70      note: '此回答由AI根据本站信息生成。最新信息请查看参考页面。',
71      error: '搜索时发生错误。请稍后再试。',
72      empty: '请输入要搜索的内容。'
73    },
74    'zh-TW': {
75      placeholder: '請輸入您想了解的內容(例如:停車費)',
76      button: '搜尋',
77      loading: '正在產生回答…',
78      sources: '參考頁面',
79      newTab: '在新分頁中開啟',
80      external: '外部網站',
81      note: '此回答由AI根據本站資訊產生。最新資訊請查看參考頁面。',
82      error: '搜尋時發生錯誤。請稍後再試。',
83      empty: '請輸入要搜尋的內容。'
84    }
85  };
86
87  /**
88   * URLのパスから言語を判定する。
89   *
90   * このサイトはサブディレクトリ型のマルチサイトなので、パスの先頭が
91   * そのまま言語を示す。検索文字列の言語を推測する必要はなく、
92   * 英語ページで日本語を入力しても英語の結果が返る。
93   */
94  function detectLang() {
95    var match = location.pathname.match(/^\/(en|ko|zh-CN|zh-TW)(?:\/|$)/);
96    return match && I18N[match[1]] ? match[1] : 'ja';
97  }
98
99  var LANG = detectLang();
100  var T = I18N[LANG];
101
102  // --------------------------------------------------------------------
103  // スタイル
104  // --------------------------------------------------------------------
105
106  var STYLE = [
107    ':host { display: block; }',
108    '* { box-sizing: border-box; }',
109    '.wrap {',
110    '  font-family: -apple-system, BlinkMacSystemFont, "Hiragino Sans", "Noto Sans JP", "Yu Gothic", Meiryo, sans-serif;',
111    '  color: #1a1a1a; line-height: 1.75; max-width: 780px;',
112    '}',
113    '.form { display: flex; gap: 8px; }',
114    '.input {',
115    '  flex: 1 1 auto; min-width: 0; padding: 12px 14px; font-size: 16px;',
116    '  border: 1px solid #c4c9d1; border-radius: 6px; background: #fff; color: inherit;',
117    '  font-family: inherit;',
118    '}',
119    '.input:focus { outline: 2px solid #0b63c5; outline-offset: 1px; border-color: #0b63c5; }',
120    '.button {',
121    '  flex: 0 0 auto; padding: 12px 22px; font-size: 15px; font-weight: 700;',
122    '  color: #fff; background: #0b63c5; border: 0; border-radius: 6px; cursor: pointer;',
123    '  font-family: inherit;',
124    '}',
125    '.button:hover:not(:disabled) { background: #0951a3; }',
126    '.button:disabled { background: #9aa5b1; cursor: default; }',
127    '.panel { margin-top: 20px; }',
128    '.panel[hidden] { display: none; }',
129    '.answer { white-space: pre-wrap; font-size: 16px; }',
130    '.loading { color: #5a6472; font-size: 15px; display: flex; align-items: center; gap: 8px; }',
131    // display を明示した要素には [hidden] 用の指定が必須。
132    // 作者スタイルはブラウザ標準の [hidden] { display: none } より優先されるため、
133    // これが無いと hidden 属性を立てても消えない。
134    '.loading[hidden] { display: none; }',
135    '.dot {',
136    '  width: 8px; height: 8px; border-radius: 50%; background: #0b63c5;',
137    '  animation: pulse 1.2s ease-in-out infinite;',
138    '}',
139    '@keyframes pulse { 0%, 100% { opacity: 0.3; } 50% { opacity: 1; } }',
140    '@media (prefers-reduced-motion: reduce) { .dot { animation: none; } }',
141    '.sources { margin-top: 20px; padding-top: 16px; border-top: 1px solid #e3e6ea; }',
142    '.sources[hidden] { display: none; }',
143    '.sources-title {',
144    '  margin: 0 0 8px; font-size: 13px; font-weight: 700; color: #5a6472;',
145    '  letter-spacing: .04em;',
146    '}',
147    '.sources ul { margin: 0; padding: 0; list-style: none; }',
148    '.sources li { margin: 0 0 6px; }',
149    '.sources a { color: #0b63c5; text-decoration: underline; font-size: 15px; }',
150    '.sources a:hover { text-decoration: none; }',
151    '.ext {',
152    '  display: inline-block; margin-left: 6px; padding: 1px 6px; border-radius: 3px;',
153    '  background: #eceff3; color: #5a6472; font-size: 11px; vertical-align: middle;',
154    '  white-space: nowrap;',
155    '}',
156    '.note { margin-top: 16px; font-size: 12px; color: #6b7481; line-height: 1.6; }',
157    '.note[hidden] { display: none; }',
158    '.score {',
159    '  margin-left: 6px; color: #6b7481; font-size: 11px;',
160    '  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;',
161    '}',
162    // 以下はスコア診断パネル。config.php の expose_scores が true のときだけ
163    // サーバから event: debug が届き、表示される。
164    '.debug {',
165    '  margin-top: 20px; padding: 10px 12px; font-size: 12px;',
166    '  border: 1px dashed #c4c9d1; border-radius: 6px; background: #fafbfc;',
167    '}',
168    '.debug[hidden] { display: none; }',
169    '.debug summary { cursor: pointer; font-weight: 700; color: #5a6472; }',
170    '.dbg-meta {',
171    '  margin: 10px 0; color: #5a6472; line-height: 1.7; word-break: break-all;',
172    '  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;',
173    '}',
174    // 表は横に長くなるため、はみ出す場合はこの中だけ横スクロールさせる
175    '.dbg-scroll { overflow-x: auto; }',
176    '.debug table { border-collapse: collapse; width: 100%; font-size: 12px; }',
177    '.debug th, .debug td {',
178    '  padding: 4px 8px; border-bottom: 1px solid #e3e6ea;',
179    '  text-align: left; white-space: nowrap;',
180    '}',
181    '.debug th { color: #6b7481; }',
182    '.debug td.num { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }',
183    '.debug tr.in { background: #eef5ff; }',
184    '.debug a { color: #0b63c5; }',
185    '.error { color: #b3261e; font-size: 15px; }',
186    '@media (prefers-color-scheme: dark) {',
187    '  .wrap { color: #e8eaed; }',
188    '  .input { background: #1f2226; border-color: #464b52; color: #e8eaed; }',
189    '  .sources { border-top-color: #3a3f45; }',
190    '  .sources-title, .loading, .note { color: #9aa5b1; }',
191    '  .ext { background: #33383e; color: #9aa5b1; }',
192    '  .sources a { color: #7cb3f5; }',
193    '  .button { background: #1f6fd0; }',
194    '  .score { color: #8b949e; }',
195    '  .debug { border-color: #464b52;
195 background: #1b1e21; }',
196    '  .debug summary, .debug th, .dbg-meta { color: #9aa5b1; }',
197    '  .debug th, .debug td { border-bottom-color: #3a3f45; }',
198    '  .debug tr.in { background: #232a33; }',
199    '  .debug a { color: #7cb3f5; }',
200    '}'
201  ].join('\n');
202
203  // --------------------------------------------------------------------
204  // 描画
205  // --------------------------------------------------------------------
206
207  function build(host) {
208    var root = host.attachShadow ? host.attachShadow({ mode: 'open' }) : host;
209
210    var style = document.createElement('style');
211    style.textContent = STYLE;
212    root.appendChild(style);
213
214    var wrap = document.createElement('div');
215    wrap.className = 'wrap';
216    wrap.setAttribute('lang', LANG);
217    root.appendChild(wrap);
218
219    var form = document.createElement('form');
220    form.className = 'form';
221    wrap.appendChild(form);
222
223    var input = document.createElement('input');
224    input.className = 'input';
225    input.type = 'search';
226    input.placeholder = T.placeholder;
227    input.setAttribute('aria-label', T.placeholder);
228    input.setAttribute('maxlength', '200');
229    input.setAttribute('autocomplete', 'off');
230    form.appendChild(input);
231
232    var button = document.createElement('button');
233    button.className = 'button';
234    button.type = 'submit';
235    button.textContent = T.button;
236    form.appendChild(button);
237
238    var panel = document.createElement('div');
239    panel.className = 'panel';
240    panel.hidden = true;
241    wrap.appendChild(panel);
242
243    var status = document.createElement('div');
244    status.className = 'loading';
245    status.hidden = true;
246    // スクリーンリーダーに読み上げ状況を伝える
247    status.setAttribute('role', 'status');
248    panel.appendChild(status);
249
250    var answer = document.createElement('div');
251    answer.className = 'answer';
252    answer.setAttribute('aria-live', 'polite');
253    panel.appendChild(answer);
254
255    var note = document.createElement('p');
256    note.className = 'note';
257    note.textContent = T.note;
258    note.hidden = true;
259    panel.appendChild(note);
260
261    var sources = document.createElement('div');
262    sources.className = 'sources';
263    sources.hidden = true;
264    panel.appendChild(sources);
265
266    // スコア診断パネル。既定では非表示で、サーバが expose_scores を
267    // 有効にしているときだけ内容が入る。折りたたんだ状態で置く。
268    var debug = document.createElement('details');
269    debug.className = 'debug';
270    debug.hidden = true;
271    panel.appendChild(debug);
272
273    return {
274      form: form,
275      input: input,
276      button: button,
277      panel: panel,
278      status: status,
279      answer: answer,
280      note: note,
281      sources: sources,
282      debug: debug
283    };
284  }
285
286  function showLoading(ui) {
287    ui.status.hidden = false;
288    ui.status.textContent = '';
289
290    var dot = document.createElement('span');
291    dot.className = 'dot';
292    ui.status.appendChild(dot);
293    ui.status.appendChild(document.createTextNode(T.loading));
294  }
295
296  function renderSources(ui, list) {
297    ui.sources.textContent = '';
298    if (!list || !list.length) {
299      ui.sources.hidden = true;
300      return;
301    }
302
303    var heading = document.createElement('p');
304    heading.className = 'sources-title';
305    heading.textContent = T.sources;
306    ui.sources.appendChild(heading);
307
308    var ul = document.createElement('ul');
309    list.forEach(function (item) {
310      if (!item || typeof item.url !== 'string') {
311        return;
312      }
313
314      var url;
315      try {
316        url = new URL(item.url, location.origin);
317      } catch (e) {
318        return;
319      }
320
321      // 自サイトのURLか、サーバが外部リンクとして明示したものだけを表示する。
322      // 外部リンクは config.php に登録されたものに限られるため、
323      // 任意のURLが紛れ込むことはない。
324      var isExternal = url.origin !== location.origin;
325      if (isExternal) {
326        if (!item.external) {
327          return;
328        }
329        if (url.protocol !== 'https:' && url.protocol !== 'http:') {
330          return;
331        }
332      }
333
334      var li = document.createElement('li');
335      var a = document.createElement('a');
336      a.href = url.href;
337      a.textContent = item.title || url.href;
338
339      // 別タブで開く。
340      // 自サイト内は noopener のみ。noreferrer を付けるとリファラが送られず、
341      // アクセス解析でサイト内の回遊が追えなくなるため付けない。
342      // 外部サイトへは noreferrer も付け、閲覧元ページを他社に渡さない。
343      a.target = '_blank';
344      a.rel = isExternal ? 'noopener noreferrer' : 'noopener';
345
346      var label = a.textContent;
347      li.appendChild(a);
348
349      if (isExternal) {
350        var badge = document.createElement('span');
351        badge.className = 'ext';
352        badge.textContent = T.external;
353        li.appendChild(badge);
354        label += '(' + T.external + ')';
355      }
356
357      // expose_scores が有効なときだけサーバから score が届く
358      if (typeof item.score === 'number') {
359        var score = document.createElement('span');
360        score.className = 'score';
361        score.textContent = item.score.toFixed(4);
362        li.appendChild(score);
363      }
364
365      // 外部サイトであること・別タブで開くことを読み上げで伝える
366      a.setAttribute('aria-label', label + '(' + T.newTab + ')');
367
368      ul.appendChild(li);
369    });
370
371    if (!ul.children.length) {
372      ui.sources.hidden = true;
373      return;
374    }
375
376    ui.sources.appendChild(ul);
377    ui.sources.hidden = false;
378  }
379
380  /**
381   * スコア診断パネルを描画する。
382   *
383   * config.php の expose_scores が true のときだけサーバから event: debug が
384   * 届く。運用担当者が精度調整に使うものなので、ページの言語に関係なく
385   * 日本語のラベルで表示する(利用者向けの文言ではない)。
386   */
387  function renderDebug(ui, data) {
388    ui.debug.textContent = '';
389
390    if (!data || !data.candidates || !data.candidates.length) {
391      ui.debug.hidden = true;
392      return;
393    }
394
395    var summary = document.createElement('summary');
396    summary.textContent = '診断: 類似度スコア(上位 ' + data.candidates.length + ' 件)';
397    ui.debug.appendChild(summary);
398
399    var settings = data.settings || {};
400    var meta = document.createElement('p');
401    meta.className = 'dbg-meta';
402    meta.textContent = [
403      'top_k=' + settings.top_k,
404      'max_chunks_per_page=' + settings.max_chunks_per_page,
405      'min_score=' + settings.min_score,
406      'title_match_bonus=' + settings.title_match_bonus,
407      'max_sources=' + settings.max_sources,
408      'source_score_margin=' + settings.source_score_margin,
409      '最高スコア=' + data.topScore,
410      '閾値通過=' + (data.passedMinScore ? 'はい' : 'いいえ')
411    ].join('  /  ');
412    ui.debug.appendChild(meta);
413
414    var scroll = document.createElement('div');
415    scroll.className = 'dbg-scroll';
416
417    var table = document.createElement('table');
418
419    var thead = document.createElement('thead');
420    var headRow = document.createElement('tr');
421    ['#', 'スコア', 'AI', 'ページ / 見出し'].forEach(function (text) {
422      var th = document.createElement('th');
423      th.textContent = text;
424      headRow.appendChild(th);
425    });
426    thead.appendChild(headRow);
427    table.appendChild(thead);
428
429    var tbody = document.createElement('tbody');
430    data.candidates.forEach(function (item) {
431      var tr = document.createElement('tr');
432      // AIに渡ったチャンクを色で区別する
433      if (item.inContext) {
434        tr.className = 'in';
435      }
436
437      var rank = document.createElement('td');
438      rank.className = 'num';
439      rank.textContent = item.rank;
440
441      var score = document.createElement('td');
442      score.className = 'num';
443      score.textContent = item.score;
444
445      var flag = document.createElement('td');
446      flag.textContent = item.inContext ? '✓' : '';
447
448      var page = document.createElement('td');
449      var link = document.createElement('a');
450      link.href = item.url;
451      link.target = '_blank';
452      link.rel = 'noopener';
453      link.textContent = item.title + (item.heading ? ' / ' + item.heading : '');
454      page.appendChild(link);
455
456      tr.appendChild(rank);
457      tr.appendChild(score);
458      tr.appendChild(flag);
459      tr.appendChild(page);
460      tbody.appendChild(tr);
461    });
462    table.appendChild(tbody);
463
464    scroll.appendChild(table);
465    ui.debug.appendChild(scroll);
466
467    ui.debug.hidden = false;
468  }
469
470  function showError(ui, message) {
471    ui.status.hidden = true;
472    ui.answer.textContent = '';
473
474    var p = document.createElement('p');
475    p.className = 'error';
476    p.textContent = message;
477    ui.answer.appendChild(p);
478  }
479
480  // --------------------------------------------------------------------
481  // 通信
482  // --------------------------------------------------------------------
483
484  /**
485   * SSE形式のテキストからイベントを取り出す。
486   * fetch のストリームを自前で読むため、パースもここで行う。
487   */
488  function parseEvents(buffer, onEvent) {
489    var parts = buffer.split('\n\n');
490    // 最後の要素は不完全な可能性があるため、呼び出し元へ戻す
491    var remainder = parts.pop();
492
493    parts.forEach(function (block) {
494      var name = 'message';
495      var data = '';
496
497      block.split('\n').forEach(function (line) {
498        if (line.indexOf('event:') === 0) {
499          name = line.slice(6).trim();
500        } else if (line.indexOf('data:') === 0) {
501          data += line.slice(5).trim();
502        }
503      });
504
505      if (!data) {
506        return;
507      }
508      try {
509        onEvent(name, JSON.parse(data));
510      } catch (e) {
511        /* 不正なJSONは無視する */
512      }
513    });
514
515    return remainder;
516  }
517
518  function search(ui, query, controller) {
519    // finished は done イベントを受け取ったかどうか。
520    // サーバが途中で落ちると done が届かないため、呼び出し元で後始末できるよう
521    // 状態を返す。
522    var state = { received: false, finished: false };
523
524    var handleEvent = function (name, data) {
525      if (name === 'sources') {
526        renderSources(ui, data);
527      } else if (name === 'debug') {
528        renderDebug(ui, data);
529      } else if (name === 'delta') {
530        if (!state.received) {
531          state.received = true;
532          ui.status.hidden = true;
533        }
534        ui.answer.appendChild(document.createTextNode(data.t || ''));
535      } else if (name === 'error') {
536        showError(ui, data.message || T.error);
537      } else if (name === 'done') {
538        state.finished = true;
539        ui.status.hidden = true;
540        // 注記は「最新の情報は参照ページをご確認ください」と案内する文言なので、
541        // 出典が1件も無いときは出さない。存在しないページを見るよう促してしまう。
542        // 該当なし・生成失敗のときも同様に出さない。
543        ui.note.hidden = !state.received
544          || !!data.noResult
545          || !!data.failed
546          || ui.sources.hidden;
547      }
548    };
549
550    return fetch(ENDPOINT, {
551      method: 'POST',
552      headers: { 'Content-Type': 'application/json' },
553      body: JSON.stringify({ q: query, lang: LANG }),
554      signal: controller ? controller.signal : undefined,
555      credentials: 'same-origin'
556    }).then(function (response) {
557      if (!response.ok) {
558        throw new Error('HTTP ' + response.status);
559      }
560
561      // ReadableStreamが使えない環境では、全文を受け取ってから一括で処理する
562      if (!response.body || !response.body.getReader) {
563        return response.text().then(function (text) {
564          parseEvents(text + '\n\n', handleEvent);
565        });
566      }
567
568      var reader = response.body.getReader();
569      var decoder = new TextDecoder('utf-8');
570      var buffer = '';
571
572      var pump = function () {
573        return reader.read().then(function (result) {
574          if (result.done) {
575            parseEvents(buffer + '\n\n', handleEvent);
576            return;
577          }
578          buffer += decoder.decode(result.value, { stream: true });
579          buffer = parseEvents(buffer, handleEvent);
580          return pump();
581        });
582      };
583
584      return pump();
585    }).then(function () {
586      return state;
587    });
588  }
589
590  // --------------------------------------------------------------------
591  // 起動
592  // --------------------------------------------------------------------
593
594  function init() {
595    var host = document.getElementById(MOUNT_ID);
596    if (!host) {
597      return;
598    }
599
600    var ui = build(host);
601    var inFlight = null;
602
603    ui.form.addEventListener('submit', function (event) {
604      event.preventDefault();
605
606      var query = ui.input.value.trim();
607      if (!query) {
608        ui.panel.hidden = false;
609        showError(ui, T.empty);
610        return;
611      }
612
613      // 連打された場合は前のリクエストを中断する
614      if (inFlight) {
615        inFlight.abort();
616      }
617      inFlight = typeof AbortController !== 'undefined' ? new AbortController() : null;
618
619      ui.panel.hidden = false;
620      ui.answer.textContent = '';
621      ui.note.hidden = true;
622      ui.sources.hidden = true;
623      ui.sources.textContent = '';
624      ui.debug.hidden = true;
625      ui.debug.textContent = '';
626      ui.button.disabled = true;
627      showLoading(ui);
628
629      search(ui, query, inFlight)
630        .then(function (result) {
631          // done が届かないまま接続が終わった場合(サーバ側の異常終了など)。
632          // これを拾わないと、表示が「回答を作成しています…」のまま止まる。
633          if (result && !result.finished) {
634            ui.status.hidden = true;
635            if (!result.received) {
636              showError(ui, T.error);
637            }
638          }
639        })
640        .catch(function (error) {
641          if (error && error.name === 'AbortError') {
642            return;
643          }
644          showError(ui, T.error);
645        })
646        .then(function () {
647          ui.button.disabled = false;
648          inFlight = null;
649        });
650    });
651  }
652
653  if (document.readyState === 'loading') {
654    document.addEventListener('DOMContentLoaded', init);
655  } else {
656    init();
657  }
658})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.