🖼️ ページ内の画像をブックマークレット経由でGoogleフォトに一括アップロードするツールを作った

どうも。**yamaimo** ([@yarnaimodev](https://twitter.com/yarnaimodev)) です。

[Webページをローカルに保存するツール](https://qiita.com/yarnaimo/items/66f3b71077d15a22699d)の人です。

今回はページ内の画像を Google フォトに一括アップロードするツールを Vue.js で作るはずが **Functional Component + Hooks** に感動して React 推しになってしまう話です。

![Honeyview_photohook-app.firebaseapp.com__urls_c=BYFxAcGcC4HpYGYEsBOBTARgQ0mgdAOYD2RBANvgMZEC2sA+pCFiEpbAOwYYCMALABMATAE4ArD1hIaWAmkiJUmHGlhkilANYBXcHnAA7AgAJQEGPGTpsuQiXJVaUmXIXgURAdsogFGbUhkAvQ8AB76RqZgUHCK1ip2pBR41HTSsv.png](https://qiita-image-store.s3.amazonaws.com/0/83774/c1e82c7d-596b-71b2-8454-af3887cae2c9.png)

## 今回の経緯

- ある Web ページの画像を一括保存したかった
- **ブラウザや環境に関わらず使える方法**がほしい
- 最初はブックマークレット経由でサイトに URL を渡してブラウザだけで一括ダウンロードできるんじゃないかと思ったけど普通にクロスドメイン制約に引っかかったのでサーバー経由でダウンロードすることにした
- ダウンロードじゃなくて **Google フォトに直接アップロード**すればいいのでは…？

## 作ったもの

サイトはこちら✨

**Photohook**
https://photohook-app.firebaseapp.com

ブックマークレットの登録方法などもこちらに書いてありますが一応貼っときます。

```photohook-bookmarklet.js
javascript: (() => { var LZString=function(){function o(o,r){if(!t[o]){t[o]={};for(var n=0;n<o.length;n++)t[o][o.charAt(n)]=n}return t[o][r]}var r=String.fromCharCode,n="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=",e="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+-$",t={},i={compressToBase64:function(o){if(null==o)return"";var r=i._compress(o,6,function(o){return n.charAt(o)});switch(r.length%4){default:case 0:return r;case 1:return r+"===";case 2:return r+"==";case 3:return r+"="}},decompressFromBase64:function(r){return null==r?"":""==r?null:i._decompress(r.length,32,function(e){return o(n,r.charAt(e))})},compressToUTF16:function(o){return null==o?"":i._compress(o,15,function(o){return r(o+32)})+" "},decompressFromUTF16:function(o){return null==o?"":""==o?null:i._decompress(o.length,16384,function(r){return o.charCodeAt(r)-32})},compressToUint8Array:function(o){for(var r=i.compress(o),n=new Uint8Array(2*r.length),e=0,t=r.length;t>e;e++){var s=r.charCodeAt(e);n[2*e]=s>>>8,n[2*e+1]=s%256}return n},decompressFromUint8Array:function(o){if(null===o||void 0===o)return i.decompress(o);for(var n=new Array(o.length/2),e=0,t=n.length;t>e;e++)n[e]=256*o[2*e]+o[2*e+1];var s=[];return n.forEach(function(o){s.push(r(o))}),i.decompress(s.join(""))},compressToEncodedURIComponent:function(o){return null==o?"":i._compress(o,6,function(o){return e.charAt(o)})},decompressFromEncodedURIComponent:function(r){return null==r?"":""==r?null:(r=r.replace(/ /g,"+"),i._decompress(r.length,32,function(n){return o(e,r.charAt(n))}))},compress:function(o){return i._compress(o,16,function(o){return r(o)})},_compress:function(o,r,n){if(null==o)return"";var e,t,i,s={},p={},u="",c="",a="",l=2,f=3,h=2,d=[],m=0,v=0;for(i=0;i<o.length;i+=1)if(u=o.charAt(i),Object.prototype.hasOwnProperty.call(s,u)||(s[u]=f++,p[u]=!0),c=a+u,Object.prototype.hasOwnProperty.call(s,c))a=c;else{if(Object.prototype.hasOwnProperty.call(p,a)){if(a.charCodeAt(0)<256){for(e=0;h>e;e++)m<<=1,v==r-1?(v=0,d.push(n(m)),m=0):v++;for(t=a.charCodeAt(0),e=0;8>e;e++)m=m<<1|1&t,v==r-1?(v=0,d.push(n(m)),m=0):v++,t>>=1}else{for(t=1,e=0;h>e;e++)m=m<<1|t,v==r-1?(v=0,d.push(n(m)),m=0):v++,t=0;for(t=a.charCodeAt(0),e=0;16>e;e++)m=m<<1|1&t,v==r-1?(v=0,d.push(n(m)),m=0):v++,t>>=1}l--,0==l&&(l=Math.pow(2,h),h++),delete p[a]}else for(t=s[a],e=0;h>e;e++)m=m<<1|1&t,v==r-1?(v=0,d.push(n(m)),m=0):v++,t>>=1;l--,0==l&&(l=Math.pow(2,h),h++),s[c]=f++,a=String(u)}if(""!==a){if(Object.prototype.hasOwnProperty.call(p,a)){if(a.charCodeAt(0)<256){for(e=0;h>e;e++)m<<=1,v==r-1?(v=0,d.push(n(m)),m=0):v++;for(t=a.charCodeAt(0),e=0;8>e;e++)m=m<<1|1&t,v==r-1?(v=0,d.push(n(m)),m=0):v++,t>>=1}else{for(t=1,e=0;h>e;e++)m=m<<1|t,v==r-1?(v=0,d.push(n(m)),m=0):v++,t=0;for(t=a.charCodeAt(0),e=0;16>e;e++)m=m<<1|1&t,v==r-1?(v=0,d.push(n(m)),m=0):v++,t>>=1}l--,0==l&&(l=Math.pow(2,h),h++),delete p[a]}else for(t=s[a],e=0;h>e;e++)m=m<<1|1&t,v==r-1?(v=0,d.push(n(m)),m=0):v++,t>>=1;l--,0==l&&(l=Math.pow(2,h),h++)}for(t=2,e=0;h>e;e++)m=m<<1|1&t,v==r-1?(v=0,d.push(n(m)),m=0):v++,t>>=1;for(;;){if(m<<=1,v==r-1){d.push(n(m));break}v++}return d.join("")},decompress:function(o){return null==o?"":""==o?null:i._decompress(o.length,32768,function(r){return o.charCodeAt(r)})},_decompress:function(o,n,e){var t,i,s,p,u,c,a,l,f=[],h=4,d=4,m=3,v="",w=[],A={val:e(0),position:n,index:1};for(i=0;3>i;i+=1)f[i]=i;for(p=0,c=Math.pow(2,2),a=1;a!=c;)u=A.val&A.position,A.position>>=1,0==A.position&&(A.position=n,A.val=e(A.index++)),p|=(u>0?1:0)*a,a<<=1;switch(t=p){case 0:for(p=0,c=Math.pow(2,8),a=1;a!=c;)u=A.val&A.position,A.position>>=1,0==A.position&&(A.position=n,A.val=e(A.index++)),p|=(u>0?1:0)*a,a<<=1;l=r(p);break;case 1:for(p=0,c=Math.pow(2,16),a=1;a!=c;)u=A.val&A.position,A.position>>=1,0==A.position&&(A.position=n,A.val=e(A.index++)),p|=(u>0?1:0)*a,a<<=1;l=r(p);break;case 2:return""}for(f[3]=l,s=l,w.push(l);;){if(A.index>o)return"";for(p=0,c=Math.pow(2,m),a=1;a!=c;)u=A.val&A.position,A.position>>=1,0==A.position&&(A.position=n,A.val=e(A.index++)),p|=(u>0?1:0)*a,a<<=1;switch(l=p){case 0:for(p=0,c=Math.pow(2,8),a=1;a!=c;)u=A.val&A.position,A.position>>=1,0==A.position&&(A.position=n,A.val=e(A.index++)),p|=(u>0?1:0)*a,a<<=1;f[d++]=r(p),l=d-1,h--;break;case 1:for(p=0,c=Math.pow(2,16),a=1;a!=c;)u=A.val&A.position,A.position>>=1,0==A.position&&(A.position=n,A.val=e(A.index++)),p|=(u>0?1:0)*a,a<<=1;f[d++]=r(p),l=d-1,h--;break;case 2:return w.join("")}if(0==h&&(h=Math.pow(2,m),m++),f[l])v=f[l];else{if(l!==d)return null;v=s+s.charAt(0)}w.push(v),f[d++]=s+v.charAt(0),h--,s=v,0==h&&(h=Math.pow(2,m),m++)}}};return i}(); const urls = [ ...new Set( [...document.querySelectorAll('img')] .map(el => el.src.trim()) .map(url => { const m1 = url.match( /^https:\/\/pbs.twimg.com\/media\/[\w-]+\.[a-z]+/m ); if (m1) { return m1[0] + ':orig'; } const m2 = url.match( /^https:\/\/pbs.twimg.com\/media\/[\w-]+\?format=\w+/m ); if (m2) { return m2[0] + '&name=orig'; } return url; }) .filter(url => url && !url.startsWith('data:')) ), ]; window.open( `https://photohook-app.firebaseapp.com?urls_c=${LZString.compressToEncodedURIComponent(urls.join(' '))}` ); })() 
```

- GitHub - yarnaimo/photohook
  https://github.com/yarnaimo/photohook

- Gist - ブックマークレット
  https://gist.github.com/yarnaimo/151e59ef7452cfdc8706b5ffb018cb06

### 操作の流れ

👆 はユーザーの操作、📱 はブックマークレットの動作

1. 👆 ブックマークレットを起動する
2. 📱 ページ内にある**画像の URL をスペースで結合し、lz-string で圧縮**する
3. 📱 それをパラメータとして Photohook を新しいタブで開く<br>
   例: `https://photohook-app.firebaseapp.com/?urls_c=JYWwhg5gpgjAdAKwA4QASklATHJA7CIA`
4. 👆 Photohook で表示された画像の中から保存したいものを選択する
5. 👆 アルバムを選択してアップロードボタンで Google フォトにアップロードする

## 構成

- Firebase (Hosting, Cloud Functions)
- TypeScript
- React
- Material Components for React
- emotion (CSS-in-JS)
- Parcel

今回 [Material Components for the web](https://material-components.github.io/material-components-web-catalog/#/) が使いたかったので当初は[リスト](https://github.com/material-components/material-components-web/blob/master/docs/framework-wrappers.md)にあった Vue.js ラッパーの BalmUI を使おうとしましたが、なかなかサンプル通りにならず結局 CSS を書き足さないといけなくなったので **[Material Components for React](https://github.com/material-components/material-components-web-react)** を使ってみました。React を使うのは初めてです。🎉

## ポイント

### Exif の撮影日時データの一括設定

アップロード画面で日付を指定すると、**Exif の撮影日時データがない画像に一括で設定**できるようになっています。

### 画像の URL は圧縮して渡す

当初は `https://photohook-app.firebaseapp.com/?urls=image1.jpg,image2.png` みたいに URL をそのまま繋げて渡してたんですが、**URL の文字数の上限が2083文字**らしく `414 URI Too Long` で結構簡単に死ぬことがわかったので [lz-string](https://github.com/pieroxy/lz-string) で圧縮して渡すことにしました。

`https://photohook-app.firebaseapp.com/?urls_c=JYWwhg5gpgjAdAKwA4QASklATHJA7CIA` という感じです。

### io-ts で実行時の型チェック

TypeScript は**実行時には型情報が消える**ので、JSON など可変データが絡んでくる場合に実行時エラーを防ごうとすると手間が増えてしまいます。
そんな場合は **[io-ts](https://github.com/gcanti/io-ts)** を使うと、**トランスパイル時のチェックと実行時のチェックの両方を一つのコードで**実現できます。

例えば io-ts でこういう型を作りたい場合は

```ts
interface RequestType {
    albumId?: string
    urls: string[]
}
```

こうなります。

```ts
import * as t from 'io-ts'

const RequestType = t.type({
    albumId: t.union([t.string, t.undefined]),
    urls: t.array(t.string),
})
```

使い方はこんな感じ。

```ts
const request = JSON.parse(body)
const validationResult = RequestType.decode(request)

if (validationResult.isLeft()) {
    // バリデーションに失敗した場合
    return
}

validationResult.value // これの型は { albumId?: string; urls: string[] }
```

## React の良さに気づく

Vue.js は JavaScript で使うには便利ですが、TypeScript で型の恩恵を受けようとするとどうしても複雑になってしまいます。
それに対して React では、**Functional Component** と v16.8 で追加された **Hooks** を使うとこのように比較的シンプルにコンポーネントを書くことができます。

```tsx:Counter.tsx
import React, { useState } from 'react'

interface Props {
    onChange: (n: number) => void
}

export const Counter: React.FC<Props> = props => {
    const [count, setCount] = useState(0)

    // count が更新されたら onChange を呼ぶ
    useEffect(() => props.onChange(count), [count])

    const increment = () => setCount(count + 1)

    return (
        <div>
            <p>Count: {count}</p>
            <button onClick={increment}>+</button>
        </div>
    )
}
```

イベントハンドラをプロパティとして渡せるのもいいですね。

TypeScript を始めてから型付けなしでは生きられなくなってしまったのでこれはかなり魅力的でした。

## Firebase

の話もしようかと思ったけどまた今度にします。

<blockquote class="twitter-tweet" data-partner="tweetdeck"><p lang="ja" dir="ltr">やっぱりFirebaseなんだよなぁ</p>&mdash; 𝕪𝕒𝕞𝕒𝕚𝕞𝕠 ⁽ᴵⱽᐟᵇⱽᴵᴵ⁾ (@yarnaimo) <a href="https://twitter.com/yarnaimo/status/1100353574104514560?ref_src=twsrc%5Etfw">February 26, 2019</a></blockquote>

## まとめ

みなさんも推しの画像を保存するときに使ってみてください。

それでは～