PageSourceSearch

https://www.linemo.jp/assets/js/json-ld-offer.js

js linemo.jp collected 2026-10-01 11:30:50 UTC 10,042 bytes, 274 lines download raw bytes

1/**
2 * 構造化データ JSON-LD: Product + offers(Offer)
3 * 要件: ルートを Product とし、その中の offers に Offer オブジェクトを格納。
4 * ページ要素 (title / meta description / canonical) と引数から動的生成し
5 * <script type="application/ld+json" id="offer-json-ld"> を挿入します。
6 *
7 * 生成例 (動的部分は [] 表記):
8 * {
9 *   "@context": "https://schema.org/",
10 *   "@type": "Product",
11 *   "name": "LINEMO",
12 *   "description": "LINEMOはソフトバンクのオンライン専用携帯電話サービスです。",
13 *   "url": "https://www.linemo.jp/",
14 *   "category": "格安SIM/格安スマホ",
15 *   "brand": { "@type": "Brand", "name": "LINEMO" },
16 *   "image": "https://www.linemo.jp/assets/images/common/logo.svg",
17 *   "offers": {
18 *     "@type": "Offer",
19 *     "name": "[titleタグのテキスト中、最初の|以降を除いた部分]",
20 *     "description": "[meta descriptionタグのcontent]",
21 *     "availabilityStarts": "[YYYY-MM-DD]",
22 *     "availabilityEnds": "[YYYY-MM-DD(任意)]",
23 *     "eligibleRegion": { "@type": "Country", "name": "JP" },
24 *     "priceSpecification": {
25 *       "@type": "PriceSpecification",
26 *       "price": "[数値]",
27 *       "priceCurrency": "JPY",
28 *       "valueAddedTaxIncluded": [true/false]
29 *     },
30 *     "additionalProperty": {
31 *       "@type": "PropertyValue",
32 *       "name": "campaignURL",
33 *       "value": "[canonical URL]"
34 *     }
35 *   }
36 * }
37 *
38 * 使用方法(例): 各ページでこのJSを読み込み、それ以降の行に以下をインライン記述
39 *  document.addEventListener('DOMContentLoaded', function(){
40 *    window.insertOfferJsonLd({
41 *      availabilityStarts: '2025-10-01',
42 *      availabilityEnds: '2025-10-31', // 任意
43 *      price: 0,                       // 数値 or 数字文字列
44 *      valueAddedTaxIncluded: false    // boolean
45 *    });
46 *  });
47 * キャンペーンが複数ある場合
48 *  document.addEventListener('DOMContentLoaded', function(){
49 *   window.insertOfferJsonLd({
50 *     offerList: [
51 *      {
52 *       availabilityStarts: '2025-10-01',
53 *       availabilityEnds: '2025-10-31',
54 *       price: 0,
55 *       valueAddedTaxIncluded: false
56 *      },
57 *      {
58 *       availabilityStarts: '2025-12-01',
59 *       availabilityEnds: '2025-12-31',
60 *       price: 1000,
61 *       valueAddedTaxIncluded: true,
62 *       name: 'クリスマスキャンペーン',
63 *       description: '12月限定の割引施策です。',
64 *       campaignURL: 'https://www.linemo.jp/campaign/xmas2025/'
65 *      }
66 *    ]
67 * 既存の script#offer-json-ld があれば置き換えます。
68 * 互換性: 関数名 insertOfferJsonLd を継続利用 (旧 API との差異は戻り値構造)。
69 */
70
71(function(global){
72	'use strict';
73
74	var SCRIPT_ID = 'offer-json-ld';
75	var DATE_RE = /^\d{4}-\d{2}-\d{2}$/; // YYYY-MM-DD
76
77	/**
78	 * 日付文字列バリデーション (YYYY-MM-DD)
79	 * @param {string|undefined|null} s
80	 * @returns {boolean}
81	 */
82	function isValidDateStr(s){
83		if(!s || typeof s !== 'string') return false;
84		if(!DATE_RE.test(s)) return false;
85		// 簡易実在日チェック: Dateでパースして逆変換一致
86		var d = new Date(s + 'T00:00:00Z');
87		if(isNaN(d.getTime())) return false;
88		var iso = d.toISOString().slice(0,10);
89		return iso === s;
90	}
91
92	/**
93	 * titleタグの最初の '|'(全角) 以前を取得。なければ全文。
94	 * @returns {string|undefined}
95	 */
96	function extractTitlePart(){
97		var el = document.querySelector('title');
98		if(!el) return undefined;
99		var text = el.textContent || '';
100		var idx = text.indexOf('|');
101		if(idx === -1) return text.trim();
102		return text.slice(0, idx).trim();
103	}
104
105	/**
106	 * meta description を取得。
107	 * @returns {string|undefined}
108	 */
109	function getMetaDescription(){
110		var meta = document.querySelector('meta[name="description"]');
111		if(meta && meta.getAttribute('content')) return meta.getAttribute('content').trim();
112		// フォールバック: og:description
113		var og = document.querySelector('meta[property="og:description"]');
114		if(og && og.getAttribute('content')) return og.getAttribute('content').trim();
115		return undefined;
116	}
117
118	/**
119	 * canonical URL を取得。
120	 * @returns {string|undefined}
121	 */
122	function getCanonical(){
123		var link = document.querySelector('link[rel="canonical"]');
124		if(link && link.getAttribute('href')) return link.getAttribute('href').trim();
125		// フォールバック: location.href
126		return (typeof location !== 'undefined') ? location.href : undefined;
127	}
128
129	/**
130	 * Product + Offer JSON-LD オブジェクトを組み立て (複数Offer対応)
131	 * @param {Object} opt
132	 * @param {string} [opt.availabilityStarts] (単一指定用 必須 YYYY-MM-DD) ※ offerList未使用時
133	 * @param {string} [opt.availabilityEnds] (単一指定用 任意 YYYY-MM-DD)
134	 * @param {number|string} [opt.price] (単一指定用 任意 数値または数字文字列。未指定なら 0)
135	 * @param {boolean|string} [opt.valueAddedTaxIncluded] (単一指定用 任意 true/false または 'true'/'false'. 未指定なら false)
136	 * @param {Array<Object>}
136 [opt.offerList] 複数指定用
137	 *   要素: { availabilityStarts: string(必須), availabilityEnds?: string, price?: number|string, valueAddedTaxIncluded?: boolean|string, campaignURL?: string }
138	 * @returns {Object|null} Product ルートオブジェクト
139	 */
140	function buildProductOffer(opt){
141		opt = opt || {};
142		var canonicalGlobal = getCanonical();
143
144		function normalizePrice(p){
145			var raw = (p === 0 || p) ? p : 0;
146			var s = '' + raw;
147			if(!/^[0-9]+(?:\.[0-9]+)?$/.test(s)){
148				console.warn('[json-ld] price が数値として解釈できません。0 を使用します');
149				return '0';
150			}
151			return s;
152		}
153
154		function normalizeVAT(v){
155			if(typeof v === 'boolean') return v;
156			if(typeof v === 'string'){
157				if(v.toLowerCase() === 'true') return true;
158				if(v.toLowerCase() === 'false') return false;
159				console.warn('[json-ld] valueAddedTaxIncluded 文字列が不正です (true/false)');
160			}
161			return false;
162		}
163
164		function buildSingleOffer(src){
165			if(!src || !isValidDateStr(src.availabilityStarts)){
166				console.warn('[json-ld] availabilityStarts が不正です。YYYY-MM-DD 形式で指定してください (offer除外)');
167				return null;
168			}
169			var endsValid = isValidDateStr(src.availabilityEnds);
170			return {
171				'@type': 'Offer',
172				// 2件目以降はページから取得できないと想定されるため override可
173				name: (src.name && typeof src.name === 'string') ? src.name : extractTitlePart(),
174				description: (src.description && typeof src.description === 'string') ? src.description : getMetaDescription(),
175				availabilityStarts: src.availabilityStarts,
176				availabilityEnds: endsValid ? src.availabilityEnds : undefined,
177				eligibleRegion: { '@type': 'Country', name: 'JP' },
178				priceSpecification: {
179					'@type': 'PriceSpecification',
180					price: normalizePrice(src.price),
181					priceCurrency: 'JPY',
182					valueAddedTaxIncluded: normalizeVAT(src.valueAddedTaxIncluded)
183				},
184				additionalProperty: {
185					'@type': 'PropertyValue',
186					name: 'campaignURL',
187					value: (src.campaignURL && typeof src.campaignURL === 'string') ? src.campaignURL : canonicalGlobal
188				}
189			};
190		}
191
192		var offersResult;
193		if(Array.isArray(opt.offerList)){
194			offersResult = opt.offerList.map(buildSingleOffer).filter(Boolean);
195			if(offersResult.length === 0){
196				console.warn('[json-ld] 有効な Offer がありません (offerList)');
197				return null;
198			}
199			// 1件ならObject、複数なら配列
200			offersResult = offersResult.length === 1 ? offersResult[0] : offersResult;
201		} else {
202			// 従来の単一指定
203			var single = buildSingleOffer({
204				availabilityStarts: opt.availabilityStarts,
205				availabilityEnds: opt.availabilityEnds,
206				price: opt.price,
207				valueAddedTaxIncluded: opt.valueAddedTaxIncluded,
208				campaignURL: opt.campaignURL,
209				name: opt.name,
210				description: opt.description
211			});
212			if(!single) return null;
213			offersResult = single;
214		}
215
216		var product = {
217			'@context': 'https://schema.org/',
218			'@type': 'Product',
219			name: 'LINEMO',
220			description: 'LINEMOはソフトバンクのオンライン専用携帯電話サービスです。',
221			url: 'https://www.linemo.jp/',
222			category: '格安SIM/格安スマホ',
223			brand: { '@type': 'Brand', name: 'LINEMO' },
224			image: 'https://www.linemo.jp/assets/images/common/logo.svg',
225			offers: offersResult
226		};
227		return product;
228	}
229
230	/**
231	 * script挿入 (既存の同ID scriptを置換)
232	 * @param {Object} offerObj JSONオブジェクト
233	 */
234	function injectScript(offerObj){
235		if(!offerObj) return;
236		try {
237			var json = JSON.stringify(offerObj, null, 2);
238			var head = document.head || document.getElementsByTagName('head')[0];
239			if(!head){
240				console.warn('[json-ld] head要素が見つかりません');
241				return;
242			}
243			var prev = document.getElementById(SCRIPT_ID);
244			if(prev && prev.parentNode){
245				prev.parentNode.removeChild(prev);
246			}
247			var s = document.createElement('script');
248			s.type = 'application/ld+json';
249			s.id = SCRIPT_ID;
250			s.text = json;
251			head.appendChild(s);
252		} catch(e){
253			console.error('[json-ld] 挿入失敗', e);
254		}
255	}
256
257	/**
258	 * 公開API
259	 * @param {Object} options
260	 * @param {string} options.availabilityStarts YYYY-MM-DD (必須)
261	 * @param {string} [options.availabilityEnds] YYYY-MM-DD (任意)
262	 */
263	function insertOfferJsonLd(options){
264		var productObj = buildProductOffer(options || {});
265		injectScript(productObj);
266		return productObj; // デバッグ用に返す
267	}
268
269	// グローバル公開 (名前衝突を避け既存を上書きしない)
270	if(!global.insertOfferJsonLd){
271		global.insertOfferJsonLd = insertOfferJsonLd;
272	}
273
274})(window || this);

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.