【2024年更新版】Google検索結果の一覧をCSVでダウンロードするブックマークレット

> 2024年5月6日、現状のGoogle検索結果で動作するように更新しました。
> ニュースタブ・動画タブ・画像タブで動作します。

# Googleの検索結果を手軽に一覧でCSV保存したい

Google検索はとても便利なので、日常的に利用しているのですが、
検索結果をまとめて一覧化したいことがありますよね。

検索画面に表示されるサイト名やページタイトル、URLをCSVファイルとして一覧で取得できれば、
ExcelやNumbers、Calcなどで使えて便利です。

スクレイピングやAPIで取得するなどの方法もありますが、
画面に表示されている検索結果を取得したい用途があるので、
ブラウザから誰でも利用できる、ブックマークレットを作りました。

> ただし、最新のブラウザでなければ動作しません。

## 使い方

ブラウザで新しいブックマークを作り、次の内容をURL欄に貼り付けます。
ブックマークの名前は、`Google検索結果CSVダウンロード`などで良いでしょう。
Googleの検索結果画面で、登録したブックマークをクリックすると動作します。

```javascript
javascript:((e,t,r,i,n,o,s,d)=>{const a=(t,r=e)=>r.querySelector(t),c=(t,r=e)=>r.querySelectorAll(t),l=(t,r=e.body)=>r.appendChild(t),v=t=>e.createElement(t),p=(e,...t)=>t.reduce((e,t)=>e.concat(t),e),u=e=>`"${e.map(e=>I(b(e)||"",[/"/g,/\n/g],['""',""])).join('","')}"`,f=e=>{let t=e;try{t=decodeURI(e)}catch(e){}return t},m=e=>e.filter(e=>!!e),b=e=>e&&"object"==typeof e&&s in e?e[s]:e||"",h=e=>e.join("\n"),g=async e=>{const r=await U(),i=e<=(O=R())[o];if(0===O[o]||i)return!1;if(r)return!0;const n=m([j(),w()]);return 0!==n[o]&&(await t.all(n),!0)},y=(e,t)=>Array.from(c(e)).map(t),j=()=>{const e=a("div[data-dt=1] > div[role=progressbar]");if(!e)return!1;const r=a("h1 + a",e.parentNode);return!!r&&(!/transform: ?scale\(0\)/.test(r.getAttribute("style"))&&new t(t=>{r.click();const i=new MutationObserver(r=>{for(const n of r)if("attributes"===n.type&&"style"===n.attributeName){const r=e.getAttribute("style");/display: ?none/.test(r)&&(i.disconnect(),t(!0))}});i.observe(e,{attributes:!0})}))},w=()=>{const r=a("#pnnext",N);if(!r)return!1;void 0===L&&(L=v("iframe"),k(L,{display:"none"}),l(L));const o=v("div");return k(o,{background:"black",color:"white",left:0,padding:"8px 20px",position:"fixed",top:0,zIndex:1e3}),o[s]="Loading...",l(o),new t(t=>{L[n]("load",()=>{N=L.contentWindow.document,o.remove();const r=v("div");r[i]=a("#rso",N)[i],c("div.ITCGwe",r).forEach(e=>e.parentNode.removeChild(e));const n=a("#rso");for(const e of r.children)l(e,n);N!==e&&(a("#botstuff")[i]=a("#botstuff",N)[i]),t(!0)},{once:!0}),L.src=r.href})},x=(e,t,r="")=>(e||"").replace(t,r),I=(e,t,r)=>t.reduce((e,t,i)=>x(e,t,r[i]),e),R=()=>m(p(y('#rso div.MjjYud > div.g, div[id^="arc-srp_"] div.MjjYud > div.g, #rso div.K7khPe, #rso div.RzdJxc, #rso div.m7jPZ',e=>{const t=a("div.yuRUbf a, div.RzdJxc a, div.m7jPZ > a.WlydOe",e);if(!t)return!1;const r=t.href;let i,n,s,d,c;if(e.classList.contains("RzdJxc"))i=a("[role=heading] span.cHaqb",e),n=a("span.Sg4azc > span",e),s=a("div.OwbDmd > span",e),d=a("div.V8fWH",e),c=a("cite",e),n=n&&x(b(n)," · ");else if(e.classList.contains("m7jPZ"))i=a("[role=heading]",t),s=a("div.OSrXXb > span",t),c=a("div.MgUUmf > span",t);else{if(i=a("h3",t),n=a("cite",t),s=a("div.VwiC3b > span.Sqrs4e span, div.IsZvec > div.fG8Fp",e),d=a("div.VwiC3b > span:not(.Sqrs4e):last-of-type, div.IsZvec > span.aCOpRe > span",e),c=a("span.VuuXrf",t),!s&&!a("cite span",t)){const e=b(n).split(" · ");try{n=e[0].trimEnd(),s=e[1].trimStart()}catch(e){}}if(!d){const t=a("div.VwiC3b",e).childNodes;d=t.item(t[o]-1).textContent}}return{href:r,decoded:f(r),title:i,breadcrumb:n,date:s,desc:d&&(s?x(b(d),b(s)).trimStart():b(d)),siteName:c}}),y("#rso > div.MjjYud [data-attrid~=images]",e=>{const t=a("a[target=_blank]",e);if(!t)return!1;const r=t.href,i=a("h3 img",e).src;return{href:r,decoded:f(r),title:a("div.toI8Rb",t),breadcrumb:i.startsWith("data:")?null:i,date:a("div.wr8GYd > span",e),description:a("div.toI8Rb",t),siteName:a("div.guK3rf > span",t)}}),y("#rso > div.MjjYud a.WlydOe",e=>{const t=e.href;return{href:t,decoded:f(t),title:a("[role=heading]",e),breadcrumb:null,date:a("div.OSrXXb > span",e),description:a("div.GI74Re",e),siteName:a("div.MgUUmf > span",e)}}),y("#rso > div.MjjYud > div > div.g",e=>{const t=a("div.xe8e1b a",e);if(!t)return!1;const r=t.href;return{href:r,decoded:f(r),title:a("h3",t),breadcrumb:a("cite",t),date:a("div.gqF9jc > span > span",e),description:a("div.ITZIwc",e),siteName:a("div.gqF9jc > span:nth-of-type(2)",e)}}))),U=()=>{const i=e.scrollingElement;return!(!i||i.scrollTop+i.clientHeight>=i.scrollHeight-50)&&new t(t=>{e.body.scrollIntoView({behavior:"smooth",block:"end"});const i=()=>{clearTimeout(o),o=r(()=>{e.removeEventListener("scroll",i),t(!0)},1e3)};let o;e[n]("scroll",i,{passive:!0}),i()})},k=(e,t)=>{for(let r in t)e.style[r]=t[r]};let L,N=e,O=[];(async()=>{const t=prompt("Enter maximum number of items as an integer","everything");if(!t)return;const i=parseInt(t);if(!(i<=0)){for(;await g(i););0!==O[o]?(alert(`Found: ${O[o]}`),void 0!==L&&L.remove(),(()=>{const t=new Blob([new Uint8Array([239,187,191]),h(p([u(d)],O.map(e=>u(Object.values(e)))))],{type:"text/csv"}),i=e.createElement("a"),n=URL.createObjectURL(t);i.download=e.title+".csv",i.href=n,i.click(),r(()=>URL.revokeObjectURL(n),1e3)})()):alert("No item found.\nIf you think this script works incorrectly contact the creator.\nThx in advance.")}})()})(document,Promise,setTimeout,"innerHTML","addEventListener","length","innerText",["href","decoded","title","breadcrumb","date","description","siteName"])
```

ページをブックマークしておき、編集画面を選んだ後、URL欄にペーストするのがコツです。

### 動作イメージ

Google Chromeのブックマークツールバーを表示させている場合のイメージです。

Google検索結果画面でブックマークをクリックすると、ブックマークレットが実行され、ダイアログが表示されます。
最初に、取得したい件数を入力してください。

例えば、100件のデータが欲しい場合は、 `100` と入力します。
そうすると、おおよそ100件のデータが取得されて、CSVファイルがダウンロードされます。

すべての検索結果が欲しい場合は、表示されている初期値のまま確定してください。
もし、初期値 `everything` が表示されていない場合は、数字以外の文字を入力してください。

![検索結果画面でブックマークを選択.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/84173/795dbe0d-407c-b5e4-a760-89a8fa0ba7d7.png)

> 2024年5月最新版から、自動で次のページが読み込まれるようになりました。

ダウンロードしたCSVファイルはそのまま開けます。
![CSVの表示.png](https://qiita-image-store.s3.amazonaws.com/0/84173/765d69b0-9bb2-48a9-1272-e578be9d5e53.png)

## 保存されるCSV

UTF-8でエンコードされています。
Excel、Numbers、CalcやGoogle Spreadsheetでそのまま開けます。

| ヘッダ         | 説明 | 空文字可能性 | 具体例 |
| ------------- | ---- | ---- | ---- |
| `href`        | URL | NO | https://ja.wikipedia.org/wiki/%E3%83%96%E3%83%83%E3%82%AF%E3%83%9E%E3%83%BC%E3%82%AF%E3%83%AC%E3%83%83%E3%83%88 |
| `decoded`     | `decodeURI()`したURL | NO | https://ja.wikipedia.org/wiki/ブックマークレット |
| `title`       | サイト名 or ページ名 | YES | ブックマークレット - Wikipedia |
| `breadcrumb`  | パンくずの表示 | YES | https://ja.wikipedia.org/wiki/ブックマークレット |
| `date`        | 更新日 | YES | 2019/1/15 |
| `description` | ページの概要 | YES | ブックマークレット (Bookmarklet) とは、ユーザーがウェブブラウザのブックマークなどから起動し、なんらかの処理を行う簡易的なプログラムのことである。携帯電話のウェブブラウザで足りない機能を補ったり、ウェブアプリケーションの処理を起動する為に使 ... |
| `siteName`    | サイトの名前 | YES | Wikipedia |

> 2024年5月最新版から、 `siteName` が追加されました。

## 対象ブラウザ

最新のブラウザを利用してください。

### 動作確認結果

2024年5月6日時点

| ブラウザ | バージョン | 動作 |
| ----------------- | ---- | ---- |
| Google Chrome     | 124.0.6367.119 (Official Build) (arm64) | OK |
| Firefox           | 125.0.3 | OK |
| Safari            | 17.4.1 | OK |
| Edge (Blink)      | 124.0.2478.80 (公式ビルド) (arm64) | OK |
| Edge (EdgeHTML)   | 動作未確認 | 今後も対応しません。 |
| Internet Explorer | 11.1082.18362.0 | NG<br>今後も対応しません。 |

## ソースコード

ブックマークレット用に変換する前のソースコードです。

<details>
<summary>ここをクリックしてソースコードを表示</summary>
<div>

```javascript:ソースコード
/**
 * @typedef EntryKeys
 * @type {['href', 'decoded', 'title', 'breadcrumb', 'date', 'description', 'siteName'] as const}
 */
/**
 * @typedef Entry
 * @type {{
 *  [Key in EntryKeys[number]]: string | Element | null | undefined
 * }}
 */

(
/**
 * @param {Document} document
 * @param {typeof Promise} Promise
 * @param {typeof setTimeout} setTimeout
 * @param {'innerHTML'} html
 * @param {'addEventListener'} on
 * @param {'length'} size
 * @param {'innerText'} text
 * @param {EntryKeys} heading
 */
(document, Promise, setTimeout, html, on, size, text, heading) => {
  /**
   * @param {string} query
   * @param {Element | undefined} doc
   */
  const $ = (query, doc = document) => doc.querySelector(query)

  /**
   * @param {string} query
   * @param {Element | undefined} doc
   */
  const $$ = (query, doc = document) => doc.querySelectorAll(query)

  /**
   * @param {Element} child
   * @param {Element | undefined} parent
   */
  const appendChild = (child, parent = document.body) => parent.appendChild(child)

  /**
   * @param {string} tag
   */
  const createElement = tag => document.createElement(tag)

  const cleanup = () => {
    if (iframe !== undefined) {
      iframe.remove()
    }
  }

  /**
   * @template T
   * @param {T[]} base
   * @param {(T | T[])[]} arr
   */
  const concat = (base, ...arr) => /** @type {T[][]} */ (arr).reduce((prev, item) => prev.concat(item), base)

  /**
   * @param {(string | Element)[]} row
   */
  const convert = row => `"${row.map(s => replaceArr(
    getText(s) || '',
    [/"/g, /\n/g],
    ['""', '']
  )).join('","')}"`

  /**
   * @param {string} href
   */
  const decode = href => {
    let out = href

    try {
      out = decodeURI(href)
    } catch (e) {}

    return out
  }

  const download = () => {
    const blob = new Blob([
      new Uint8Array([0xEF, 0xBB, 0xBF]),
      joinLines(concat(
        [
          convert(heading)
        ],
        ret.map(item => convert(Object.values(item)))
      ))
    ], {
      type: 'text/csv'
    })

    const a = document.createElement('a')
    const url = URL.createObjectURL(blob)

    a.download = document.title + '.csv'
    a.href = url

    a.click()

    setTimeout(() => URL.revokeObjectURL(url), 1000)
  }

  /**
   * @template T
   * @param {T[]} array
   * @returns {(Exclude<T, 0 | '' | false | null | undefined>)[]}
   */
  const filter = array => array.filter(item => !!item)

  /**
   * @param {HTMLElement | null | undefined} element
   */
  const getText = element => element && typeof element === 'object' && text in element
    ? element[text]
    : element || ''

  /**
   * @param {string[]} array
   */
  const joinLines = array => array.join('\n')

  /**
   * @param {number} maxItems
   */
  const loop = async (maxItems) => {
    const canScroll = await scroll()

    ret = scan()

    const hasReachedMax = maxItems <= ret[size]

    if (ret[size] === 0 || hasReachedMax) {
      return false
    }

    if (canScroll) {
      return true
    }

    const next = filter([
      nextAuto(),
      nextOldSchool()
    ])

    if (next[size] === 0) {
      return false
    }

    await Promise.all(next)

    return true
  }

  const main = async () => {
    const input = prompt('Enter maximum number of items as an integer', 'everything')

    if (!input) {
      return
    }

    const maxItems = parseInt(input)

    if (maxItems <= 0) {
      return
    }

    while (await loop(maxItems)) {
      continue
    }

    if (ret[size] === 0) {
      alert('No item found.\nIf you think this script works incorrectly contact the creator.\nThx in advance.')
      return
    }

    alert(`Found: ${ret[size]}`)

    cleanup()
    download()
  }

  /**
   * @template T
   * @param {string} query
   * @param {(item: Element) => T} callback
   */
  const mapElements = (query, callback) => Array.from($$(query)).map(callback)

  const nextAuto = () => {
    const progressBar = $('div[data-dt=1] > div[role=progressbar]')

    if (!progressBar) {
      return false
    }

    const moreLink = $('h1 + a', progressBar.parentNode)

    if (!moreLink) {
      return false
    }

    if (/transform: ?scale\(0\)/.test(moreLink.getAttribute('style'))) {
      return false
    }

    /**
     * @type {Promise<true>}
     */
    const promise = new Promise((resolve) => {
      moreLink.click()

      const observer = new MutationObserver((mutations) => {
        for (const mutation of mutations) {
          if (mutation.type === 'attributes' && mutation.attributeName === 'style') {
            const currentValue = progressBar.getAttribute('style')

            if (/display: ?none/.test(currentValue)) {
              observer.disconnect()

              resolve(true)
            }
          }
        }
      })

      observer.observe(progressBar, {
        attributes: true
      })
    })

    return promise
  }

  const nextOldSchool = () => {
    /**
     * @type {HTMLAnchorElement}
     */
    const pnnext = $('#pnnext', doc)

    if (!pnnext) {
      return false
    }

    if (iframe === undefined) {
      iframe = createElement('iframe')

      setStyle(iframe, {
        display: 'none'
      })

      appendChild(iframe)
    }

    const progress = createElement('div')

    setStyle(progress, {
      background: 'black',
      color: 'white',
      left: 0,
      padding: '8px 20px',
      position: 'fixed',
      top: 0,
      zIndex: 1000
    })

    progress[text] = 'Loading...'

    appendChild(progress)

    /**
     * @type {Promise<true>}
     */
    const promise = new Promise(resolve => {
      iframe[on]('load', () => {
        doc = iframe.contentWindow.document
        progress.remove()

        const wrapper = createElement('div')
        wrapper[html] = $('#rso', doc)[html]

        $$('div.ITCGwe', wrapper).forEach(item => item.parentNode.removeChild(item))

        const last = $('#rso')
        for (const child of wrapper.children) {
          appendChild(child, last)
        }

        if (doc !== document) {
          $('#botstuff')[html] = $('#botstuff', doc)[html]
        }

        resolve(true)
      }, {
        once: true
      })

      iframe.src = pnnext.href
    })

    return promise
  }

  /**
   * @param {string | null | undefined} string
   * @param {string | RegExp} from
   * @param {string} to
   */
  const replace = (string, from, to = '') => (string || '').replace(from, to)

  /**
   * @param {string | null | undefined} string
   * @param {(string | RegExp)[]} from
   * @param {string[]} to
   */
  const replaceArr = (string, from, to) => from.reduce((prev, pattern, index) => replace(prev, pattern, to[index]), string)

  const scan = () => filter(concat(
    // All tab
    mapElements('#rso div.MjjYud > div.g, div[id^="arc-srp_"] div.MjjYud > div.g, #rso div.K7khPe, #rso div.RzdJxc, #rso div.m7jPZ', item => {
      /**
       * @type {HTMLAnchorElement}
       */
      const a = $('div.yuRUbf a, div.RzdJxc a, div.m7jPZ > a.WlydOe', item)
      if (!a) {
        return false
      }

      const href = a.href
      /**
       * @type {HTMLElement | string | null | undefined}
       */
      let title
      /**
       * @type {HTMLElement | string | null | undefined}
       */
      let breadcrumb
      /**
       * @type {HTMLElement | string | null | undefined}
       */
      let date
      /**
       * @type {HTMLElement | string | null | undefined}
       */
      let desc
      /**
       * @type {HTMLElement | string | null | undefined}
       */
      let siteName

      if (item.classList.contains('RzdJxc')) {
        // YouTube
        title = $('[role=heading] span.cHaqb', item)
        breadcrumb = $('span.Sg4azc > span', item)
        date = $('div.OwbDmd > span', item)
        desc = $('div.V8fWH', item)
        siteName = $('cite', item)

        breadcrumb = breadcrumb && replace(getText(breadcrumb), ' · ')
      } else if (item.classList.contains('m7jPZ')) {
        // News
        title = $('[role=heading]', a)
        date = $('div.OSrXXb > span', a)
        siteName = $('div.MgUUmf > span', a)
      } else {
        title = $('h3', a)
        breadcrumb = $('cite', a)
        date = $('div.VwiC3b > span.Sqrs4e span, div.IsZvec > div.fG8Fp', item)
        desc = $('div.VwiC3b > span:not(.Sqrs4e):last-of-type, div.IsZvec > span.aCOpRe > span', item)
        siteName = $('span.VuuXrf', a)

        if (!date && !$('cite span', a)) {
          const parts = getText(breadcrumb).split(' · ')
          try {
            breadcrumb = parts[0].trimEnd()
            date = parts[1].trimStart()
          } catch (error) {}
        }

        if (!desc) {
          const nodes = $('div.VwiC3b', item).childNodes
          desc = nodes.item(nodes[size] - 1).textContent
        }
      }

      return /** @type {Entry} */ ({
        href,
        decoded: decode(href),
        title,
        breadcrumb,
        date,
        desc: desc && (
          date
            ? replace(getText(desc), getText(date)).trimStart()
            : getText(desc)
          ),
        siteName
      })
    }),

    // Images tab
    mapElements('#rso > div.MjjYud [data-attrid~=images]', item => {
      /**
       * @type {HTMLAnchorElement | null}
       */
      const a = $('a[target=_blank]', item)
      if (!a) {
        return false
      }

      const href = a.href
      const src = $('h3 img', item).src

      return /** @type {Entry} */ ({
        href,
        decoded: decode(href),
        title: $('div.toI8Rb', a),
        breadcrumb: src.startsWith('data:') ? null : src,
        date: $('div.wr8GYd > span', item),
        description: $('div.toI8Rb', a),
        siteName: $('div.guK3rf > span', a)
      })
    }),

    // News tab
    mapElements('#rso > div.MjjYud a.WlydOe', item => {
      const href = /** @type {HTMLAnchorElement} */ (item).href

      return /** @type {Entry} */ ({
        href,
        decoded: decode(href),
        title: $('[role=heading]', item),
        breadcrumb: null,
        date: $('div.OSrXXb > span', item),
        description: $('div.GI74Re', item),
        siteName: $('div.MgUUmf > span', item)
     })
    }),

    // Videos tab
    mapElements('#rso > div.MjjYud > div > div.g', item => {
      /**
       * @type {HTMLAnchorElement | null}
       */
      const a = $('div.xe8e1b a', item)
      if (!a) {
        return false
      }

      const href = a.href

      return /** @type {Entry} */ ({
        href,
        decoded: decode(href),
        title: $('h3', a),
        breadcrumb: $('cite', a),
        date: $('div.gqF9jc > span > span', item),
        description: $('div.ITZIwc', item),
        siteName: $('div.gqF9jc > span:nth-of-type(2)', item)
      })
    })
  ))

  const scroll = () => {
    const scrollingElement = document.scrollingElement

    if (!scrollingElement || scrollingElement.scrollTop + scrollingElement.clientHeight >= scrollingElement.scrollHeight - 50) {
      return false
    }

    /**
     * @type {Promise<true>}
     */
    const promise = new Promise(resolve => {
      document.body.scrollIntoView({
        behavior: 'smooth',
        block: 'end'
      })

      const listener = () => {
        clearTimeout(timer)
        timer = setTimeout(() => {
          document.removeEventListener('scroll', listener)
          resolve(true)
        }, 1000)
      }
      let timer

      document[on]('scroll', listener, {
        passive: true
      })
      listener()
    })

    return promise
  }

  /**
   * @param {HTMLElement} target
   * @param {Object.<string, string>} style
   */
  const setStyle = (target, style) => {
    for (let key in style) {
      target.style[key] = style[key]
    }
  }

  let doc = document

  /**
   * @type {HTMLIFrameElement}
   */
  let iframe

  /**
   * @type {Entry[]}
   */
  let ret = []

  main()
})(document, Promise, setTimeout, 'innerHTML', 'addEventListener', 'length', 'innerText', [
  'href',
  'decoded',
  'title',
  'breadcrumb',
  'date',
  'description',
  'siteName'
])
```

> ※ Minifierは[こちら](https://skalman.github.io/UglifyJS-online/ "UglifyJS 3: Online JavaScript minifier")を使用しています。
> ※ Minify後の文字数をなるべく削減するために、少々トリッキーなコーディングをしています。

</div></details>

## 免責

Google検索結果画面の仕様が変わると、動作しなくなることが予想されます。
その際にはご容赦ください。