1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

GoogleKeepに保存したデータをスプレッドシートで一覧化する方法について

1
Last updated at Posted at 2025-09-27

自分の備忘録用に残しておきたいと思います。

前提

前々から自分がいいなと思った記事なんかをスプレッドシートに自動的に一覧化できないかなと考えていたので、今回ChatGPTの力を借りながら、ある程度まで自動化することができました。
エンジニアの方ならば、全自動できそうな気がしますが、僕にはそれはできなかったので、とりあえず半自動化をしています。

使うツールについて

  1. Google Kppe
  2. Google Drive(できればアプリもダウンロード)
  3. Google SpreadSheet

実施方法

1. GoogleKeepに記事を保存していきます。

2. 保存されたデータはGoogle Takeoutというサービスでエクスポートができます。

こんな感じでエクスポートするサービスを選択できます。アクセスするとデフォルトで様々なサービスにチェックがついてしまっているので、「選択をすべて解除」を押下し余計なサービスのチェックを外した上で「Google Keep」を探してチェックをつけましょう。
スクリーンショット 2025-09-27 23.29.02.png

3. エクスポートをする時にエクスポート先を選択できるのでGoogle Driveを選択しておきます。

スクリーンショット 2025-09-27 23.33.14.png

4. スプレッドシートを開き、AppScriptを開き添付のコードを貼り付けてください(コードは長いので別リンクとして置いておきます)。

ファイル名はなんでもいいですが私はやりたいことそのまま「keepナレッジ取得.gs」としています。
スクリーンショット_2025-09-27_23_39_41.png
あ、ソースコードですが1点だけ修正が必要です。3行目のURL(モザイクがかかっているところ)をあなたがダウンロードしたKeepのデータが保管しているGoogleDriveのリンクに差し替えてください。
1. こんな感じであなたがダウンロードしたデータがGoogledriveにあると思いますので、こちらを開いてください。
スクリーンショット 2025-09-27 23.47.59.png
2. 開いたらURLをコピーして貼り替えればOKです。
スクリーンショット_2025-09-27_23_48_34.png
終わったら実行を押下して保存アイコンを押せば「Keep Importer」というアイコンが出るはずです。
複数選択肢がありますが「解凍フォルダから取り込む」だけの利用で大丈夫です。他はデバッグ用なので気にしないでください。
スクリーンショット_2025-09-27_23_42_15.png

5. GoogleDrive内で先ほどダウンロードしたファイルを解凍します。

さっきのスプレッドシートのボタンをそのまま押下して使えればめちゃくちゃ楽なのですが、Zipファイルの状態を勝手に読み込むようなことができなかったので、手動の解答が必要です(ここやり方知ってるエンジニアさんいらっしゃいましたら是非教えてください!)。
解凍したファイルを同じ場所に配置し、スプレッドシートの「Keep Importer」から「解凍フォルダから取り込む」を押下することで自動でスプレッドシートに一覧として表示されます。また、リンク被りがないように見ており、リンク被りがあった際にはスプレットシートへの保存をスキップしてくれるようにはなっています。

6. Keepに保存した内容がスプレッドシートに

こんな感じでKeepに保存した記事をスプレッドシートに取り込むことができました。
自分の勉強用に見返るのもいいし、NotebookeLLMなんかに入れて要約させたり、情報源が一覧化されていると、色々使い用はあるかなと思いますので、皆さんも是非ご活用ください。
スクリーンショット 2025-09-28 0.24.36.png

添付用のソースコード

qiita.rb
/***** 設定 *****/
// 「Takeout」「Takeout 2」など解凍済みフォルダが入っている親フォルダのURL
const PARENT_FOLDER_URL = 'ここにURLを貼り付けてね';

const SHEET_NAME = 'KeepNotes';
const HEADERS = ['タイトル','本文','URL','ラベル','作成日','更新日','キー'];

// 一度に読む最大ファイル数(安全用)
const MAX_FILES = 20000;

/***** メニュー *****/
function onOpen() {
  SpreadsheetApp.getUi()
    .createMenu('Keep Importer')
    .addItem('解凍フォルダ(Takeout配下)から取り込む', 'importFromUnzippedTakeout')
    .addSeparator()
    .addItem('デバッグ:対象フォルダ/件数をログ', 'debugListKeepFolders_')
    .addItem('デバッグ:最初の数件のJSON/HTML名をZipContents風に出力', 'debugListSampleEntries_')
    .addToUi();
}

/***** メイン:解凍済みの Takeout/Keep を直読み *****/
function importFromUnzippedTakeout() {
  try {
    const sheet = ensureSheet_(SHEET_NAME, HEADERS);
    const parentId = parseDriveIdFromUrl_(PARENT_FOLDER_URL);
    if (!parentId) throw new Error('PARENT_FOLDER_URL からIDを抽出できません');

    const parentMeta = resolveIdTypeWithoutAdvanced_(parentId);
    if (parentMeta.kind !== 'folder') throw new Error('URLはフォルダを指していません');

    // 親直下を再帰走査して「Takeout」「Takeout 2」など → その直下の「Keep」フォルダを収集
    const takeoutKeeps = findKeepFoldersUnderParent_(parentMeta.folder);
    if (takeoutKeeps.length === 0) throw new Error('「Takeout/Keep」フォルダが見つかりません(解凍場所を確認)');

    // Keepフォルダ配下から .json(優先)と .html(フォールバック)を収集
    const entries = collectKeepFiles_(takeoutKeeps, MAX_FILES);
    if (entries.length === 0) throw new Error('Keep配下に .json/.html が見つかりません');

    const existingKeys = loadExistingKeys_(sheet);
    let added = 0;

    // JSONを先、次にHTML(JSONがあればHTMLはスキップ)
    const jsonFiles = entries.filter(e => e.nameL.endsWith('.json'));
    const htmlFiles = entries.filter(e => e.nameL.endsWith('.html'));

    let stats = { jsonTried: 0, jsonParsed: 0, jsonKeep: 0, jsonTrashed: 0, jsonAppended: 0,
                  htmlTried: 0, htmlParsed: 0, htmlAppended: 0 };

    // 1) JSON処理
    for (const it of jsonFiles) {
      stats.jsonTried++;
      const b = it.blob;
      let raw = '';
      try { raw = b.getDataAsString('UTF-8'); } catch (_) { raw = b.getDataAsString(); }
      if (!raw) continue;

      let j;
      try { j = JSON.parse(raw); stats.jsonParsed++; } 
      catch (e) { Logger.log('[WARN] JSON parse failed: ' + it.path + ' | ' + e); continue; }

      // KeepのJSONか(ゆるめ判定)
      if (!isKeepJson_(j)) continue;
      stats.jsonKeep++;

      if (j.isTrashed) { stats.jsonTrashed++; continue; }

      const title   = safe_(j.title);
      const text    = safe_(j.textContent);
      const url     = extractUrlFromKeepJson_(j); // annotations/WEBLINK優先
      const labels  = Array.isArray(j.labels) ? j.labels.map(l => safe_(l.name)).join(', ') : '';
      const created = usecToISO_(j.createdTimestampUsec);
      const updated = usecToISO_(j.userEditedTimestampUsec);

      const key = buildKey_(title, text, url, created, it.path);
      if (!key || existingKeys.has(key)) continue;

      sheet.appendRow([title, text, url, labels, created, updated, key]);
      existingKeys.add(key);
      added++; stats.jsonAppended++;
    }

    // 2) HTMLフォールバック(同一キー重複はSetで回避)
    for (const it of htmlFiles) {
      stats.htmlTried++;
      const b = it.blob;
      let html = '';
      try { html = b.getDataAsString('UTF-8'); } catch (_) { html = b.getDataAsString(); }
      if (!html) continue;

      let rec;
      try {
        rec = parseKeepXhtml_(html, it.path); // {title,text,url,labels,created,updated,key}
        stats.htmlParsed++;
      } catch (e) {
        Logger.log('[WARN] HTML parse failed: ' + it.path + ' | ' + e);
        continue;
      }
      if (!rec) continue;
      if (!rec.key || existingKeys.has(rec.key)) continue;

      sheet.appendRow([rec.title, rec.text, rec.url, rec.labels, rec.created, rec.updated, rec.key]);
      existingKeys.add(rec.key);
      added++; stats.htmlAppended++;
    }

    Logger.log('[STATS] JSON tried=' + stats.jsonTried + ' parsed=' + stats.jsonParsed +
               ' keep=' + stats.jsonKeep + ' trashed=' + stats.jsonTrashed + ' appended=' + stats.jsonAppended +
               ' | HTML tried=' + stats.htmlTried + ' parsed=' + stats.htmlParsed + ' appended=' + stats.htmlAppended);

    SpreadsheetApp.getActive().toast('新規 ' + added + ' 件を追加しました');
  } catch (err) {
    Logger.log('[ERROR] ' + (err && err.stack ? err.stack : err));
    SpreadsheetApp.getActive().toast('Exception: ' + (err.message || err));
    throw err;
  }
}

/***** デバッグ *****/
function debugListKeepFolders_() {
  const parentId = parseDriveIdFromUrl_(PARENT_FOLDER_URL);
  if (!parentId) throw new Error('URLからID抽出不可');
  const meta = resolveIdTypeWithoutAdvanced_(parentId);
  if (meta.kind !== 'folder') throw new Error('URLはフォルダではありません');

  const keeps = findKeepFoldersUnderParent_(meta.folder);
  Logger.log('[INFO] Keep folders found: ' + keeps.length);
  keeps.forEach((f, i) => Logger.log('  [' + i + '] ' + f.getName() + ' / ' + f.getId()));

  const out = getOrCreateSheet_('KeepFolders');
  out.clear(); out.appendRow(['index','name','id','pathLike','files(json)','files(html)']);
  keeps.forEach((kf, i) => {
    const {jsonCount, htmlCount, pathLike} = quickCountInKeepFolder_(kf);
    out.appendRow([i, kf.getName(), kf.getId(), pathLike, jsonCount, htmlCount]);
  });
  SpreadsheetApp.getActive().toast('KeepFolders を更新しました');
}

function debugListSampleEntries_() {
  const parentId = parseDriveIdFromUrl_(PARENT_FOLDER_URL);
  if (!parentId) throw new Error('URLからID抽出不可');
  const meta = resolveIdTypeWithoutAdvanced_(parentId);
  const keeps = meta.kind === 'folder' ? findKeepFoldersUnderParent_(meta.folder) : [];
  const entries = collectKeepFiles_(keeps, 300); // サンプル
  const out = getOrCreateSheet_('EntriesSample');
  out.clear(); out.appendRow(['path','size(bytes)']);
  entries.slice(0, 300).forEach(e => out.appendRow([e.path, e.blob.getBytes().length]));
  SpreadsheetApp.getActive().toast('EntriesSample を更新しました(最大300件)');
}

/***** 収集系 *****/
// 親フォルダ配下から「Takeout」「Takeout 2」など → その直下の「Keep」フォルダを収集
function findKeepFoldersUnderParent_(parentFolder) {
  const result = [];
  // 再帰で「Takeout…」という名前のフォルダを拾う
  function walk(folder) {
    const subs = folder.getFolders();
    while (subs.hasNext()) {
      const sf = subs.next();
      const name = (sf.getName() || '').trim();
      // Takeout / Takeout 2 / Takeout 3 ... を想定
      if (/^takeout(\s*\d+)?$/i.test(name)) {
        // 直下に Keep があれば採用
        const kids = sf.getFoldersByName('Keep');
        while (kids.hasNext()) result.push(kids.next());
      }
      // 下層にも Takeout があるかもしれないので継続
      walk(sf);
    }
  }
  walk(parentFolder);
  return result;
}

// Keepフォルダ配下から .json と .html を再帰で収集
function collectKeepFiles_(keepFolders, maxItems) {
  const out = [];
  function walk(folder, path) {
    const files = folder.getFiles();
    while (files.hasNext()) {
      const f = files.next();
      const name = f.getName() || '';
      const nameL = name.toLowerCase();
      if (nameL.endsWith('.json') || nameL.endsWith('.html') || nameL.endsWith('.xhtml')) {
        out.push({blob: f.getBlob(), path: path + '/' + name, nameL});
        if (out.length >= maxItems) return true;
      }
    }
    const subs = folder.getFolders();
    while (subs.hasNext()) {
      const sf = subs.next();
      if (walk(sf, path + '/' + sf.getName())) return true;
    }
    return false;
  }
  for (const kf of keepFolders) {
    if (walk(kf, kf.getName())) break;
  }
  return out;
}

// ざっくり件数
function quickCountInKeepFolder_(keepFolder) {
  let jsonCount = 0, htmlCount = 0;
  const pathLike = '.../' + keepFolder.getName();
  const it = keepFolder.getFiles();
  while (it.hasNext()) {
    const f = it.next();
    const n = (f.getName() || '').toLowerCase();
    if (n.endsWith('.json')) jsonCount++;
    else if (n.endsWith('.html') || n.endsWith('.xhtml')) htmlCount++;
  }
  return {jsonCount, htmlCount, pathLike};
}

/***** パース・整形 *****/
function extractUrlFromKeepJson_(j) {
  // annotations[].url(WEBLINK)を最優先 → textContent からURL抽出
  if (j && Array.isArray(j.annotations)) {
    const weblink = j.annotations.find(a => a && a.source === 'WEBLINK' && a.url);
    if (weblink && typeof weblink.url === 'string' && weblink.url.trim()) return weblink.url.trim();
  }
  if (j && typeof j.url === 'string' && j.url.trim()) return j.url.trim();
  const t = (j && j.textContent) ? String(j.textContent) : '';
  const m = t.match(/https?:\/\/[^\s)]+/);
  return m ? m[0] : '';
}

// XHTML(.html) から Keep の内容を抽出(可能な範囲で)
function parseKeepXhtml_(html, path) {
  // XHTML なので XmlService でパース(失敗時は例外)
  const doc = XmlService.parse(html);
  const root = doc.getRootElement(); // html
  // タイトル(head/title に日時文字列があることが多い)
  const head = root.getChild('head', root.getNamespace());
  const titleNode = head ? head.getChild('title', root.getNamespace()) : null;
  const timeTitle = titleNode ? titleNode.getText() : '';

  // 本文・タイトル・ラベルを class 名から抽出(構造はエクスポートHTMLに準拠)
  // <div class="note"> ... <div class="title">, <div class="content">, <div class="chips"><span class="chip">...</span>
  const body = root.getChild('body', root.getNamespace());
  if (!body) return null;

  // 深さ優先で要素を走査し、class取得のため属性を見る
  let title = '', text = '', labelsArr = [], firstUrl = '';
  function walk(node) {
    if (node.getName && node.getName() === 'div') {
      const cls = (node.getAttribute('class') || {getValue:()=>''}).getValue();
      if (cls.indexOf('title') >= 0) title = collectText_(node);
      if (cls.indexOf('content') >= 0) {
        const t = collectText_(node);
        text = t;
        // content 内の <a href> を最初のURLとして採用
        const links = findLinks_(node);
        if (links.length && !firstUrl) firstUrl = links[0];
      }
      if (cls.indexOf('chips') >= 0) {
        labelsArr = findChips_(node);
      }
    }
    (node.getChildren ? node.getChildren() : []).forEach(ch => walk(ch));
  }
  walk(body);

  const labels = labelsArr.join(', ');
  const url = firstUrl || extractUrlFromText_(title + '\n' + text);
  // created/updated は <title> に日時(例: "2025/09/23 22:10:50")が入っていることが多いので両方に同値を入れる
  const iso = parseDateLikeToISO_(timeTitle);
  const created = iso;
  const updated = iso;

  const key = buildKey_(title, text, url, created, path);
  return {title: safe_(title), text: safe_(text), url: safe_(url), labels, created, updated, key};
}

// テキスト収集
function collectText_(node) {
  let s = node.getText ? node.getText() : '';
  (node.getChildren ? node.getChildren() : []).forEach(ch => { s += collectText_(ch); });
  return s.trim();
}
function findLinks_(node) {
  const out = [];
  if (node.getName && node.getName() === 'a') {
    const hrefAttr = node.getAttribute('href');
    if (hrefAttr) {
      const href = hrefAttr.getValue();
      if (/^https?:\/\//i.test(href)) out.push(href);
    }
  }
  (node.getChildren ? node.getChildren() : []).forEach(ch => {
    findLinks_(ch).forEach(u => out.push(u));
  });
  return out;
}
function findChips_(node) {
  const out = [];
  function walk(n) {
    const cls = (n.getAttribute && n.getAttribute('class')) ? n.getAttribute('class').getValue() : '';
    if (cls.indexOf('chip') >= 0) {
      out.push((n.getText ? n.getText() : '').trim());
    }
    (n.getChildren ? n.getChildren() : []).forEach(walk);
  }
  walk(node);
  return out.filter(Boolean);
}

function extractUrlFromText_(s) {
  const m = (s || '').match(/https?:\/\/[^\s)]+/);
  return m ? m[0] : '';
}

// マイクロ秒(usec)→ ISO
function usecToISO_(usec) {
  if (!usec) return '';
  const n = Number(usec);
  if (!Number.isFinite(n)) return '';
  const ms = Math.floor(n / 1000);
  const d = new Date(ms);
  return isNaN(d.getTime()) ? '' : d.toISOString();
}

// 「キー」の作成
function buildKey_(title, text, url, createdIso, path) {
  return (url || (title || '') + '\n' + (text || '') || createdIso || path || '').trim();
}

/***** JSONの“Keepらしさ”判定(ゆるめ) *****/
function isKeepJson_(j) {
  if (!j || typeof j !== 'object') return false;
  const hasAnyContent = (typeof j.textContent === 'string') || (typeof j.title === 'string');
  const hasAnyTimestamp = !!(j.createdTimestampUsec || j.userEditedTimestampUsec);
  const hasKeepish = ('isTrashed' in j) || Array.isArray(j.labels) || Array.isArray(j.annotations);
  return (hasAnyContent && hasAnyTimestamp) || hasKeepish;
}

/***** URL→ID & フォルダ解決(Advanced不要) *****/
function parseDriveIdFromUrl_(url) {
  if (!url || typeof url !== 'string') return '';
  const m1 = url.match(/[?&]id=([a-zA-Z0-9_-]{10,})/); if (m1) return m1[1];
  const m2 = url.match(/\/file\/d\/([a-zA-Z0-9_-]{10,})/); if (m2) return m2[1];
  const m3 = url.match(/\/folders\/([a-zA-Z0-9_-]{10,})/); if (m3) return m3[1];
  return '';
}
function resolveIdTypeWithoutAdvanced_(id) {
  try { const folder = DriveApp.getFolderById(id);
    return { kind: 'folder', folder, name: folder.getName(), mime: 'application/vnd.google-apps.folder' };
  } catch (_) {}
  try { const file = DriveApp.getFileById(id);
    return { kind: 'file', file, name: file.getName(), mime: file.getMimeType() };
  } catch (_) {}
  throw new Error('IDにアクセスできません(存在/権限/URL形式を確認)');
}

/***** シート補助 *****/
function ensureSheet_(name, headers) {
  const ss = SpreadsheetApp.getActive();
  let sh = ss.getSheetByName(name);
  if (!sh) sh = ss.insertSheet(name);
  let current = [];
  if (sh.getLastRow() >= 1 && sh.getLastColumn() > 0) {
    current = sh.getRange(1, 1, 1, sh.getLastColumn()).getValues()[0];
  }
  if (current.join('\t') !== headers.join('\t')) {
    sh.clear();
    sh.getRange(1, 1, 1, headers.length).setValues([headers]);
  }
  return sh;
}
function getOrCreateSheet_(name) {
  const ss = SpreadsheetApp.getActive();
  return ss.getSheetByName(name) || ss.insertSheet(name);
}
function loadExistingKeys_(sheet) {
  const keys = new Set();
  const lastRow = sheet.getLastRow();
  if (lastRow <= 1) return keys;
  const header = sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0];
  let keyColIdx = header.indexOf('キー') + 1;
  if (keyColIdx <= 0) {
    keyColIdx = header.length + 1;
    sheet.getRange(1, keyColIdx).setValue('キー');
  }
  const numRows = lastRow - 1;
  if (numRows <= 0) return keys;
  const keyRange = sheet.getRange(2, keyColIdx, numRows, 1).getValues();
  for (let i = 0; i < keyRange.length; i++) {
    const k = (keyRange[i][0] || '').toString().trim();
    if (k) keys.add(k);
  }
  return keys;
}

/***** ユーティリティ *****/
// "2025/09/23 22:10:50" などを ISO に寄せる(失敗時は空)
function parseDateLikeToISO_(s) {
  if (!s) return '';
  // yyyy/MM/dd HH:mm:ss
  const m = s.match(/(\d{4})\/(\d{2})\/(\d{2})\s+(\d{2}):(\d{2})(?::(\d{2}))?/);
  if (!m) return '';
  const yyyy = Number(m[1]), MM = Number(m[2]) - 1, dd = Number(m[3]);
  const hh = Number(m[4]), mi = Number(m[5]), ss = Number(m[6] || '0');
  const d = new Date(yyyy, MM, dd, hh, mi, ss);
  return isNaN(d.getTime()) ? '' : d.toISOString();
}
function safe_(v) { return (v == null) ? '' : String(v); }

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?