マキモノ
Google Workspace無料✅ 公式検証済みv1.0.0 / 更新

GASで商品ページのURLから品名・説明・画像・価格を抜いて登録フォームを自動入力する(文字コード自動判定つき)

JSON-LD→OGP→title の3段抽出、ヘッダー/metaを信じず化け文字最少で復号(楽天EUC-JP実測)、画像はDriveに保存。Node/Playwright/実環境の検証手順と落とし穴つき

出品者: cr@orgiast.jp📖 読込 約5,134トークン (約8円)💰 コスパ 10倍
トークン節約メーター85%節約
ゼロからAIに作らせた場合約6万トークン
このMDを読ませた場合約9,000トークン

約5.1万トークンの節約 (API料金換算で約77円分)。 要件定義・技術調査・試行錯誤ぶんのトークンがまるごと不要になります。※ 出品者申告とレビューに基づく推定値。モデル・タスク内容により変動します。

この巻物について

「GASで商品ページのURLから品名・説明・画像・価格を抜いて登録フォームを自動入力する(文字コード自動判定つき)」は、Google WorkspaceカテゴリのAI指示書(MDファイル)です。JSON-LD→OGP→title の3段抽出、ヘッダー/metaを信じず化け文字最少で復号(楽天EUC-JP実測)、画像はDriveに保存。Node/Playwright/実環境の検証手順と落とし穴つきこの巻物をAIに読み込ませると、ゼロから設計・調査する場合に比べて 約5.1万トークン(API料金換算で約77円)・85%のトークンを節約できます。

カテゴリ
Google Workspace
対応AI
claude-code、cursor、codex-cli
ライセンス
商用利用可 (再販不可)
価格
無料
ゼロから開発時
約6万トークン
この巻物使用時
約9,000トークン
節約量
約5.1万トークン (約77円)
更新日
2026-10-18

使い方 (AIに渡す3つの方法)

いちばん簡単なのはワンライナー。Claude Code のターミナルに貼るだけです。

⬇ .md をダウンロード
claude "https://makimono-md.vercel.app/api/v1/files/gas-url/raw を読み込んで、この指示書どおりに実装して"
claude-codecursorcodex-cliライセンス: 商用利用可 (再販不可)

中身

GAS で商品ページの URL から品名・説明・画像・価格を抜いて登録フォームを自動入力する(文字コード自動判定つき)

社内の備品台帳・購入申請・在庫登録など「商品ページの URL を貼ったら登録欄が埋まる」機能を、Google Apps Script(HtmlService の Web アプリ)だけで作る手順。LLM は使わず決定的に抽出するので無料・再現性あり。実装から検証まで AI にそのまま渡せる粒度で書く。

結論(先に設計)

  1. 抽出順は JSON-LD(Product) → OGP → <title> / <meta name="description"> の3段フォールバック。EC サイトの大半は JSON-LD か OGP を持つ。
  2. 本文の復号は 「Content-Type ヘッダー」「<meta charset>」「UTF-8 / EUC-JP / Shift_JIS」を全部候補にして、U+FFFD(化け文字)が最も少ない結果を採用する。楽天市場はヘッダーが charset=EUC-JP なのに実体が UTF-8 のことがあり、宣言を信じると全文が化ける(実測)。
  3. 画像は og:image または JSON-LD image の URL を 登録時に UrlFetchApp で取得して Drive に保存し、シートには Drive のファイル URL を書く(外部 URL を直接書くと後で消える)。
  4. 品名は「【楽天市場】…:店名」「Amazon.co.jp: … : カテゴリ」「商品名 | サイト名」の飾りを落とす。区切りで分割して最長の区切りを商品名とみなすだけで実用になる。
  5. 画面側は「URL 欄に貼った瞬間(paste)に読み取り」「品名は空の時だけ上書き」「写真はプレビューとチェックボックスで登録可否を選ばせる」。

サーバー側(GAS)

function fetchProductDraft(url) {
  url = String(url || '').trim();
  if (!/^https?:\/\/\S+$/i.test(url)) return { success: false, message: 'http(s) で始まる URL を入力してください' };
  const res = UrlFetchApp.fetch(url, {
    muteHttpExceptions: true, followRedirects: true,
    headers: { 'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1', 'Accept-Language': 'ja,en;q=0.8' },
  });
  if (res.getResponseCode() >= 400) return { success: false, message: 'HTTP ' + res.getResponseCode() };
  const html = decodeBody_(res).slice(0, 1500000);
  const draft = parseProductHtml_(html, url);
  draft.sourceUrl = url;
  if (!draft.name && !draft.description) return { success: false, message: '商品名を読み取れませんでした' };
  return { success: true, draft: draft };
}

// 宣言を候補の先頭に置きつつ、化け文字が最少の復号結果を採用する
function decodeBody_(res) {
  const ct = (res.getHeaders() || {})['Content-Type'] || '';
  const latin1 = res.getContentText('ISO-8859-1');          // バイト1:1なので <meta charset> を安全に探せる
  const declared = [normCharset_((ct.match(/charset=["']?([\w-]+)/i) || [])[1]),
                    normCharset_((latin1.slice(0, 4000).match(/<meta[^>]+charset=["']?([\w-]+)/i) || [])[1])];
  const candidates = [];
  declared.concat(['UTF-8', 'EUC-JP', 'Shift_JIS']).forEach(c => { if (c && candidates.indexOf(c) === -1) candidates.push(c); });
  let best = '', bestBad = Infinity;
  for (const c of candidates) {
    let t; try { t = res.getContentText(c); } catch (e) { continue; }
    const bad = (t.match(/\uFFFD/g) || []).length;
    if (bad < bestBad) { best = t; bestBad = bad; }
    if (bestBad === 0) break;
  }
  return best;
}
function normCharset_(cs) {
  const c = String(cs || '').toLowerCase().replace(/_/g, '-');
  if (/^(x-)?sjis$|^shift-jis$|^windows-31j$|^ms932$|^cp932$/.test(c)) return 'Shift_JIS';
  if (/^euc-?jp$|^x-euc-jp$/.test(c)) return 'EUC-JP';
  if (/^utf-?8$/.test(c)) return 'UTF-8';
  return cs || '';
}

function parseProductHtml_(html, url) {
  const ld = jsonLdProduct_(html);
  let name = '', description = '', image = '', price = '';
  if (ld) {
    name = cleanTitle_(firstString_(ld.name));
    description = cleanText_(firstString_(ld.description));
    image = firstString_(ld.image);
    const offer = Array.isArray(ld.offers) ? ld.offers[0] : ld.offers;
    if (offer && (offer.price || offer.lowPrice)) price = String(offer.price || offer.lowPrice);
  }
  if (!name) name = cleanTitle_(meta_(html, 'og:title') || (html.match(/<title[^>]*>([\s\S]*?)<\/title>/i) || [])[1] || '');
  if (!description) description = cleanText_(meta_(html, 'og:description') || meta_(html, 'description'));
  if (!image) image = meta_(html, 'og:image');
  if (image && /^\/\//.test(image)) image = 'https:' + image;
  if (image && /^\//.test(image)) { const o = String(url).match(/^https?:\/\/[^/]+/); if (o) image = o[0] + image; }
  return { name: name.slice(0, 120), description: description.slice(0, 300),
           image: /^https?:\/\//i.test(image) ? image : '', price: price.replace(/[^\d.]/g, ''),
           site: (String(url).match(/^https?:\/\/([^/]+)/) || ['', ''])[1] };
}

function jsonLdProduct_(html) {
  const re = /<script[^>]+type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi;
  let m;
  while ((m = re.exec(html))) {
    try { const p = findProduct_(JSON.parse(m[1].trim()), 0); if (p) return p; } catch (e) { /* 壊れた JSON-LD は無視 */ }
  }
  return null;
}
function findProduct_(node, depth) {           // @graph / mainEntity / 配列の中も見る
  if (!node || depth > 6) return null;
  if (Array.isArray(node)) { for (const n of node) { const r = findProduct_(n, depth + 1); if (r) return r; } return null; }
  if (typeof node !== 'object') return null;
  const types = [].concat(node['@type']);
  if (types.some(t => String(t || '').toLowerCase() === 'product')) return node;
  if (node['@graph']) return findProduct_(node['@graph'], depth + 1);
  if (node.mainEntity) return findProduct_(node.mainEntity, depth + 1);
  return null;
}
function firstString_(v) {
  if (v == null) return '';
  if (typeof v === 'string') return v;
  if (Array.isArray(v)) return firstString_(v[0]);
  if (typeof v === 'object') return firstString_(v.url || v.contentUrl || v.name || v['@id']);
  return String(v);
}
function meta_(html, key) {   // property/name と content の順序が逆のサイトにも対応
  const re1 = new RegExp('<meta[^>]+(?:property|name)=["\']' + key + '["\'][^>]*content=["\']([^"\']*)["\']', 'i');
  const re2 = new RegExp('<meta[^>]+content=["\']([^"\']*)["\'][^>]*(?:property|name)=["\']' + key + '["\']', 'i');
  const m = html.match(re1) || html.match(re2);
  return m ? decodeEntities_(m[1]).trim() : '';
}
function decodeEntities_(s) {
  const named = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', yen: '¥', copy: '©', times: '×', hellip: '…', mdash: '—', ndash: '–', deg: '°', reg: '®', trade: '™' };
  return String(s == null ? '' : s)
    .replace(/&#x([0-9a-f]+);/gi, (_, h) => String.fromCodePoint(parseInt(h, 16)))
    .replace(/&#(\d+);/g, (_, d) => String.fromCodePoint(Number(d)))
    .replace(/&([a-z]+);/gi, (m, n) => Object.prototype.hasOwnProperty.call(named, n.toLowerCase()) ? named[n.toLowerCase()] : m);
}
function cleanText_(s) {
  return decodeEntities_(String(s == null ? '' : s).replace(/<br\s*\/?>/gi, ' ').replace(/<[^>]*>/g, '')).replace(/[\s ]+/g, ' ').trim();
}
function cleanTitle_(title) {
  let t = cleanText_(title).replace(/^【[^】]{1,20}】\s*/, '').replace(/^Amazon(\.co\.jp)?\s*[::]\s*/i, '');
  const parts = t.split(/\s*(?:\||||│|\s-\s|\s–\s|\s—\s|:|\s:\s)\s*/).map(p => p.trim()).filter(Boolean);
  if (parts.length > 1) t = parts.reduce((a, b) => (b.length > a.length ? b : a), '');
  return t.slice(0, 120);
}

// 画像を Drive に保存して file URL を返す(登録時に呼ぶ。失敗しても登録自体は成立させる)
function saveImageFromUrl_(imageUrl, code, name, folderId) {
  const res = UrlFetchApp.fetch(imageUrl, { muteHttpExceptions: true, followRedirects: true, headers: { 'User-Agent': 'Mozilla/5.0', 'Accept': 'image/*' } });
  if (res.getResponseCode() >= 400) return { success: false, message: 'HTTP ' + res.getResponseCode() };
  const blob = res.getBlob();
  const type = String(blob.getContentType() || '');
  if (type.indexOf('image/') !== 0) return { success: false, message: '画像ではありません (' + type + ')' };
  if (blob.getBytes().length > 10 * 1024 * 1024) return { success: false, message: '画像が大きすぎます' };
  const ext = type.indexOf('png') !== -1 ? '.png' : type.indexOf('webp') !== -1 ? '.webp' : '.jpg';
  blob.setName(code + '_' + String(name).replace(/[\\/:*?"<>|]/g, '_').slice(0, 60) + ext);
  const file = DriveApp.getFolderById(folderId).createFile(blob);
  try { file.setSharing(DriveApp.Access.ANYONE_WITH_LINK, DriveApp.Permission.VIEW); } catch (e) { /* 共有失敗は続行 */ }
  return { success: true, url: file.getUrl(), id: file.getId() };
}

appsscript.json の oauthScopes に https://www.googleapis.com/auth/script.external_request と https://www.googleapis.com/auth/drive が必要。

画面側(HtmlService 内の JS)

  • URL 欄の paste イベントで setTimeout(() => fetch, 0)(paste 直後は value がまだ古い)。Enter でも読み取る。
  • 品名欄は data-autofilled="1" を付けて「自動入力分だけ上書き・手入力は守る」。備考は「説明 / 参考価格: N円 / 出典: URL」を改行で追記。
  • 画像はプレビュー <img> と「この写真を登録する」チェックボックス(既定 ON)。登録 API に imageUrl を渡す。
  • 読み取り失敗は理由(HTTP 404 など)を表示して手入力に誘導する。

検証(AI にやらせる順)

  1. Node テスト: GAS の純粋関数(parseProductHtml_ / decodeBody_ / cleanTitle_)を vm.runInContext で読み込み、(a) 実際の商品ページを curl で保存したフィクスチャ、(b) JSON-LD の @graph 内 Product、(c) OGP だけ、(d) title だけ、(e) 何も無い、で期待値を assert。 decodeBody_ は TextDecoder('euc-jp' | 'shift_jis') で getContentText(charset) を模し、EUC-JP の生バイトを固定値で用意する(Node に日本語エンコーダは無い)。「ヘッダーが EUC-JP と偽っているが実体 UTF-8」と「実体 EUC-JP だがヘッダー utf-8」の両方を通す。
  2. ブラウザ描画テスト: Playwright で google.script.run を Proxy スタブに差し替え、URL 貼り付け → 品名・備考・プレビューが埋まる → 登録呼び出しに imageUrl が乗ることを assert。
  3. 実環境: デプロイ後に GAS 側から実 URL を1回取得し、品名が化けていないことを確認する(ローカル curl で正常でも GAS は別の文字コードの応答を受けることがある。楽天で実測)。画像保存はシートの一時コピー上で登録を走らせ、Drive にファイルが出来て image/* であることを確認してからゴミ箱へ。

落とし穴

  • ヘッダーの charset を信じない(楽天: ヘッダー EUC-JP・meta utf-8・実体はどちらも起こる)。
  • JSON-LD の name にも店名が付くことがある(楽天「商品名 | 店名 楽天市場店」)→ cleanTitle_ を JSON-LD の name にも通す。
  • <meta content="..." property="og:description"> のように属性順が逆のサイトがある → 正規表現を2本持つ。
  • String.prototype.normalize('NFKC') は GAS V8 で使える。検索の表記ゆれ(半角カナ・全角英数)吸収に便利。
  • 画像の外部 URL をシートに直接書かない(後で消える・ホットリンク禁止のサイトがある)。Drive に保存して Drive の URL を書く。

よくある質問

+「GASで商品ページのURLから品名・説明・画像・価格を抜いて登録フォームを自動入力する(文字コード自動判定つき)」とは何ですか?

JSON-LD→OGP→title の3段抽出、ヘッダー/metaを信じず化け文字最少で復号(楽天EUC-JP実測)、画像はDriveに保存。Node/Playwright/実環境の検証手順と落とし穴つき

+どれくらいトークン(費用)を節約できますか?

ゼロから開発すると約6万トークンかかりますが、この巻物を使えば約9,000トークンで済みます。差し引き約5.1万トークン(API料金換算で約77円)・85%の節約です。

+どうやって使いますか?

無料です。MDファイルを Claude Code などのAIに読み込ませるだけ。ワンライナーをターミナルに貼れば実装が始まります。要件定義や技術調査を省いて実装だけにトークンを使えます。

+どのAIツールに対応していますか?

claude-code、cursor、codex-cli に対応しています。

+商用利用できますか?

ライセンスは「商用利用可 (再販不可)」です。

🤝 自分でAIを動かすのは、まだ不安…という方へ

この巻物の内容を、AIを使うプロに丸ごと任せることもできます。姉妹サービスAI代行堂なら「LINEで頼むだけで、仕事が完成」。

AI代行堂を見る →

関連する巻物

この巻物、誰かのトークンも救えます

𝕏 で節約レシートをシェア