SNSを見ていて「この投稿、あとで見返したい」と思うことは多い。
そこで作ったのがX(旧Twitter)やInstagramの投稿を保存し、あとから検索・整理・分析できるChrome拡張「SNS Manual Clipper」だ。
最初は
投稿ページのDOMから必要な情報を取得して保存すればいい
くらいに考えていた。
ところが実際に作ってみると、これがなかなか難しい。
特に苦労したのが
- Xの投稿をどう特定するか
- 引用ポストをどう扱うか
- 認証バッジをどう判定するか
- 絵文字をどう取得するか
- Xの画像URLをどう扱うか
- Instagramの画像をどう重複排除するか
- Content Scriptの二重実行をどう防ぐか
といった部分だった。
この記事では実際にContent Scriptを実装していく中で遭遇した問題と、その解決方法についてまとめる。
最初は「DOMから取ればいい」と思っていた
Chrome拡張でSNSの投稿情報を取得する場合、基本的な考え方は単純だ。
例えばXなら
const article = document.querySelector('article[data-testid="tweet"]');
のように投稿を表す要素を取得し
- 投稿本文
- ユーザー名
- 投稿日時
- 画像
- 動画
- 投稿URL
などをDOMから取り出していけばいい。
実際にXでは以下のようなセレクタを利用している。
const X_SELECTORS = {
tweet: 'article[data-testid="tweet"]',
tweetText: '[data-testid="tweetText"]',
userName: '[data-testid="User-Name"]',
tweetPhoto: '[data-testid="tweetPhoto"]',
time: 'time',
video: 'video',
verifiedBadge: '[data-testid="icon-verified"]'
};
しかしSNSのDOMは一般的なWebサイトのように
この要素が必ずこの場所に存在する
とは限らない。
画面上には複数の投稿が存在することもあるし、引用ポストのように投稿の中に別の投稿が存在するケースもある。
ここからが本番だった。
Xの投稿を「最初のarticle」と決めつけてはいけない
最初に問題になったのが取得対象となる投稿の特定だ。
例えば
const article = document.querySelector('article[data-testid="tweet"]');
とすると一見うまくいく。
しかし実際にはページ内に複数の article が存在する可能性がある。
そのため
最初に見つかったarticle = 現在表示している投稿
とは限らない。
そこで現在のURLや投稿URLに含まれるステータスIDなどを利用して対象の投稿を特定するようにした。
イメージとしては
現在のページURL
↓
status IDを取得
↓
投稿へのリンクを探す
↓
該当するarticleを特定
↓
投稿情報を抽出
という流れだ。
つまり
DOMの順番に頼るのではなく投稿自身を識別できる情報を使う
という考え方に変えた。
SNSのDOMを扱う場合、この考え方はかなり重要だった。
一番苦労したのが「引用ポスト」
Xで特に厄介だったのが引用ポストだ。
通常の投稿なら
article
├─ 投稿本文
├─ 投稿者
└─ 画像
という構造を想定できる。
しかし引用ポストになると
article
├─ 引用元とは別の投稿情報
└─ 内部に別のarticle
├─ 引用元本文
├─ 引用元ユーザー
└─ 引用元画像
という構造になる。
ここで単純に
article.querySelector('[data-testid="tweetText"]');
などとしてしまうと外側の投稿ではなく引用元の投稿本文を取得してしまう可能性がある。
そのため実装では外側の投稿と内部に存在する引用投稿を区別して処理するようにした。
考え方としては
対象article
│
├─ 外側の投稿
│
└─ 内側の引用投稿
を意識して、それぞれの範囲を分けて検索する。
SNSのDOM取得では
「何を検索するか」だけではなく「どの範囲を検索するか」
が重要になる。
「どこを検索するのか」問題
DOM取得ではセレクタそのものに目が行きがちだ。
例えば
article.querySelector('[data-testid="User-Name"]');
というコードだけを見ると問題なさそうに見える。
しかし対象の article の内部に別の投稿が存在する場合、検索範囲が広すぎる。
そのため
投稿全体
↓
投稿者情報が存在する範囲
↓
その中からユーザー名・バッジなどを取得
というように検索範囲を限定する必要がある。
これは引用ポストだけではない。
今後SNS側のDOM構造が変更された場合にも、
できるだけ狭い範囲から必要な情報を取得する
という設計のほうが影響範囲を抑えやすい。
認証バッジも「Tweet全体から探す」と事故る
認証バッジの取得も意外と面倒だった。
最初は
article.querySelector('[data-testid="icon-verified"]');
だけでもよさそうに思える。
しかし引用ポストなどを考えると投稿全体から探すのは危険になる。
そこでまず投稿者情報を表す User-Name の領域を探し、その中を中心に認証バッジを探すようにした。
さらにSNS側のDOM変更に備えて複数の方法で判定する。
例えば
aria-labeldata-testidrole="img"
などを利用して認証バッジを探す。
つまり
投稿全体
↓
User-Name領域
↓
認証バッジ候補
↓
aria-label / data-testid / roleなどで判定
という構造だ。
一つのセレクタだけに依存するとSNS側のちょっとした変更で壊れてしまう。
絵文字も普通のtextContentでは少し面倒
投稿本文を取得するだけなら
element.textContent;
で済みそうに見える。
しかしSNSでは絵文字が通常のテキストではなく画像要素として表現されている場合がある。
そのため本文を再帰的に探索しながら
- 通常のテキスト → そのまま取得
-
img→altを取得 -
svg→ 無視
という処理を行っている。
イメージとしては
function extractTextWithEmoji(node) {
if (node.nodeType === Node.TEXT_NODE) {
return node.nodeValue || '';
}
if (node.nodeType !== Node.ELEMENT_NODE) {
return '';
}
if (node.tagName === 'IMG') {
return node.getAttribute('alt') || '';
}
if (node.tagName === 'SVG') {
return '';
}
return Array.from(node.childNodes)
.map(extractTextWithEmoji)
.join('');
}
単純に textContent を取得するだけではなく
画面上で「文字に見えているもの」をどう復元するか
という視点が必要になった。
Xのリンクカードという別問題
Xの投稿には本文や画像とは別にリンクカードが表示されることがある。
例えば
投稿本文
┌─────────────────┐
│ Webサイト │
│ タイトル │
│ サムネイル │
└─────────────────┘
のようなものだ。
これは通常の投稿画像とは別の構造になっている。
そのためリンクカードは投稿画像とは分けて取得するようにした。
取得する情報としては
- URL
- タイトル
- サムネイル
- ドメイン
などを扱う。
ここでも
投稿に含まれる画像を全部拾えばいい
という考え方ではなく
その画像が投稿画像なのか、リンクカードなのか
を区別する必要があった。
Xの画像URLも一筋縄ではいかない
Xの画像取得でも問題が出た。
画像要素からURLを取る場合、
img.currentSrc
img.src
img.getAttribute('src')
など複数の候補が考えられる。
さらにXではプロフィール画像や絵文字なども画像として存在する。
そのため単純に
document.querySelectorAll('img')
として全部取得するわけにはいかない。
本拡張機能では
-
pbs.twimg.comの画像を対象にする -
profile_imagesを除外 - 絵文字を除外
- アバターなどを除外
- URLのパラメータを整理
といった処理を行っている。
画像URLについても
画像URLを取得
↓
対象ドメインか確認
↓
プロフィール画像などを除外
↓
画像URLを正規化
↓
保存対象に追加
という段階を踏んでいる。
Instagramでさらに難易度が上がった
Xだけでもかなり苦労したがInstagramではさらに画像取得が複雑になった。
Instagramの画像では単純に img.src を取得するだけでは十分ではない。
実際には
currentSrcsrcdata-srcdata-lazy-srcsrcsetdata-srcset-
source要素のsrcset
など複数の場所から画像URL候補を集める必要がある。
考え方としては
画像要素
├─ currentSrc
├─ src
├─ data-src
├─ data-lazy-src
├─ srcset
└─ data-srcset
↓
URL候補を収集
↓
不要なURLを除外
↓
重複排除
↓
高解像度画像を選択
という流れだ。
さらにInstagram側のCDN URLやプロフィール画像なども考慮する必要がある。
「同じ画像が7枚取れる」問題
ここで、かなりSNSらしい問題が発生した。
1枚の画像についてDOMから複数のURL候補を取得すると
https://example.com/image.jpg
https://example.com/image.jpg?width=640
https://example.com/image.jpg?width=1080
https://example.com/image.jpg?...
のように同じ画像を指していると思われるURLが複数取得されることがある。
さらにURLが違っていても実体として同じ画像の場合がある。
そのため
new Set(urls);
だけでは不十分だった。
URLの重複排除だけでは
同じ画像を複数枚保存してしまう
という問題が残る。
URLではなく「画像そのもの」を比較する
そこで画像を実際に取得し、そのバイナリからSHA-256ハッシュを計算する方法を利用した。
イメージとしては
画像URL A ──┐
画像URL B ──┼→ 画像を取得 → SHA-256 → ハッシュ比較
画像URL C ──┘
同じバイナリなら同じハッシュになる。
例えば
const hashBuffer = await crypto.subtle.digest(
'SHA-256',
arrayBuffer
);
のようにしてハッシュを計算する。
これによって
URLは違うが中身が完全に同じ画像
を重複として判定できる。
SHA-256だけでは解決しない
ここでさらに問題が出た。
同じ画像でもSNS側が複数の解像度を用意している場合
画像A
1920 × 1080
画像B
1280 × 720
画像C
640 × 360
となる。
見た目は同じ画像でも画像データそのものは異なる。
当然SHA-256も異なる。
つまり、
SHA-256が違う = 別の画像
とは限らない。
ここで
同じ画像の異なる解像度をどう扱うか
という問題が発生した。
画像のURLパスをグループキーとして使う
そこで画像URLについて
- hostname
- pathname
を使ってグループ化する方法を採用した。
一方でクエリパラメータはグループ化の際に無視する。
例えば
https://cdn.example.com/image/abc.jpg?width=640
https://cdn.example.com/image/abc.jpg?width=1080
https://cdn.example.com/image/abc.jpg?width=1920
なら
hostname
cdn.example.com
pathname
/image/abc.jpg
を同じグループとして扱う。
こうすることで
同じ画像の異なるサイズ
をまとめて比較できる。
最終的には「一番大きい画像」を採用
同じ画像の候補が複数ある場合、どれを保存するか。
ここでは画像の幅と高さを取得し
const pixels = width * height;
として総画素数を比較する。
例えば
640 × 360
= 230,400 pixels
1280 × 720
= 921,600 pixels
1920 × 1080
= 2,073,600 pixels
なら最も大きい1920×1080の画像を採用する。
同じ解像度の場合はBase64化したデータの長さを比較して、より大きいものを優先する。
最終的な処理は
画像URL候補を収集
↓
画像を取得
↓
SHA-256で完全一致を排除
↓
hostname + pathnameでグループ化
↓
画像サイズを取得
↓
総画素数を比較
↓
最大解像度の画像を採用
となった。
最初は「画像URLを取るだけ」と考えていた処理が最終的にはここまで大きくなった。
重複排除の処理
全体像を整理すると以下のようになる。
画像URL候補
│
▼
┌────────┐
│ URLを正規化 │
└───┬────┘
│
▼
┌────────┐
│ 画像を取得 │
└───┬────┘
│
▼
┌─────────┐
│ SHA-256計算 │
└───┬─────┘
│
▼
完全一致を除外
│
▼
hostname + pathname
でグループ化
│
▼
画像サイズを取得
│
▼
最大解像度を選択
│
▼
保存対象画像
単純な「URLのSet」から始めて最終的には画像そのものと解像度まで考慮することになった。
カルーセルでは「1投稿1画像」にしてはいけない
SNSでは1つの投稿に複数画像が含まれることもある。
特にInstagramではカルーセル投稿がある。
そのため
投稿
├─ 画像1
├─ 画像2
├─ 画像3
└─ 画像4
という構造を維持する必要がある。
重複排除を強くしすぎると逆に
別の画像まで同じものとして扱ってしまう
可能性がある。
したがって
重複排除すること
と
投稿内の複数画像を維持すること
の両方を考える必要がある。
SNSのデータ取得では、「重複しているように見えるもの」と「本当に別のデータ」を区別する設計が重要になる。
SNSのDOM取得では「単一セレクタ依存」が危険
ここまでの話を整理するとSNSのDOM取得で一番怖いのは、
const element = document.querySelector('特定のセレクタ');
だけに依存してしまうことだ。
SNS側のUIが変更されると
今まで取得できていた
↓
DOM変更
↓
セレクタが一致しない
↓
取得できない
ということが普通に起こる。
そのため実装ではできるだけ
- 複数の候補を持つ
- 検索範囲を限定する
- URLなどDOM以外の情報も利用する
- 取得できなかった場合のフォールバックを用意する
という考え方を採用した。
「取得できない」の原因は一つではない
SNSの投稿を取得できなかった場合
セレクタが間違っている
だけが原因とは限らない。
例えば
- 対象投稿を間違えている
- 引用投稿を取得している
- DOMの読み込みがまだ終わっていない
- 画像URLが別の属性に入っている
- CDN URLが複数存在する
- プロフィール画像を拾っている
- リンクカード画像を拾っている
- Content Scriptが二重に動いている
など原因はいくらでもある。
そのためデバッグするときも
取得できない
↓
「セレクタが悪い」と決めつけない
↓
対象要素は正しいか?
検索範囲は正しいか?
データはどこに入っているか?
処理が二重実行されていないか?
↓
原因を切り分ける
という考え方が必要だった。
Content Script自体の二重実行問題
SNSのDOM取得処理とは別にChrome拡張ならではの問題もあった。
Content Scriptが意図せず複数回初期化されると
- イベントリスナーが複数登録される
- 同じ処理が複数回実行される
- メッセージを複数回受け取る
といった問題が起こる。
そこでグローバルな初期化フラグを使い
if (window.__SNS_MANUAL_CLIPPER_INITIALIZED__) {
// すでに初期化済み
} else {
window.__SNS_MANUAL_CLIPPER_INITIALIZED__ = true;
// 初期化処理
}
という形で二重初期化を防ぐ。
SNSのDOMだけを見ていると気づきにくいが
Content Scriptそのものが何回実行される可能性があるか
も考える必要がある。
抽出処理をまとめる
本拡張機能ではプラットフォームごとに抽出処理を分けている。
大まかには
EXTRACT_POST
│
▼
プラットフォーム判定
│
▼
┌───┴────┐
│ │
▼ ▼
X Instagram
│ │
▼ ▼
X用抽出 Instagram用抽出
│ │
└───┬────┘
▼
投稿データ
│
▼
呼び出し元へ返す
という構造だ。
XとInstagramではDOM構造も画像の扱いも大きく異なるため、無理に一つの抽出ロジックにまとめるより、プラットフォームごとに処理を分けたほうが扱いやすい。
一番大変だったのは「仕様書がないこと」
今回の開発で特に感じたのが
SNSには自分が必要としている仕様書が存在しない
ということだった。
一般的なWebサービスなら、
API仕様
↓
レスポンス形式
↓
データ取得
という形で進められる。
しかしDOMから取得する場合は
現在のDOM
↓
自分で観察
↓
仮説を立てる
↓
実装
↓
実際の投稿で確認
↓
例外発見
↓
修正
というサイクルになる。
さらにSNSは継続的にUIが変更される。
つまり今日動いたコードが将来も動く保証はない。
これがSNS向けChrome拡張開発の難しいところだと思う。
開発中はだいたいこうなる
実際の開発では、こんな感じの繰り返しだった。
「投稿本文取れた!」
↓
「画像も取れた!」
↓
「引用ポストで壊れた」
↓
修正
↓
「引用ポスト直った!」
↓
「認証バッジがおかしい」
↓
修正
↓
「Instagramで画像が7枚になる」
↓
修正
↓
「高解像度画像と低解像度画像が別画像扱いになる」
↓
修正
↓
「今度はカルーセルが消えた」
↓
修正
最初に想定していた「DOMから必要な値を抜くだけ」という世界とは、かなり違った。
今回の開発で学んだこと
今回の実装を通してSNSのDOMを扱うChrome拡張では単純なDOM操作以上に「例外を前提にする」ことが重要だと感じた。
特に重要だったのは以下の点。
DOMの順番を信用しない
「最初のarticle」「最初の画像」のような決め打ちは危険。
可能ならURLやIDなど対象を特定できる情報を使う。
検索範囲を意識する
querySelector() で何を探すかだけではなくどの要素の中から探しているのかを意識する。
特に引用ポストのような入れ子構造では重要。
複数の取得方法を用意する
一つのセレクタ、一つの属性だけに依存しない。
currentSrc、src、srcset など複数の候補を扱うことでSNS側の構造変更への耐性を高められる。
URLだけを信用しない
画像ではURLが違っても同じ画像の場合がある。
逆に同じようなURLでも解像度が違えば別のデータになる。
そのため
URL → 画像本体 → ハッシュ → 解像度
という複数段階で判断する必要があった。
フォールバックを用意する
SNSのDOMは変化する。
そのため
方法A
↓
失敗
↓
方法B
↓
失敗
↓
方法C
という逃げ道を用意しておくと壊れにくい。
「正常系」より「変な投稿」を見る
開発中に役立ったのは普通の投稿よりも、
- 引用ポスト
- 複数画像
- リンクカード付き投稿
- 絵文字を大量に含む投稿
- 動画付き投稿
- 特殊なプロフィール状態
などを実際に試すことだった。
普通の投稿だけを見ていると実装が完成したように見える。
しかし実際には特殊ケースで簡単に壊れる。
まとめ
本拡張機能を作る前は、
SNSの投稿を取得して保存するだけなら、それほど難しくないだろう
と思っていた。
実際に作ってみると難しかったのはデータを保存する部分ではなく
SNS上に存在する投稿を、どう正確に特定して抽出するか
だった。
特に
- Xの投稿特定
- 引用ポスト
- 認証バッジ
- 絵文字
- リンクカード
- Xの画像URL
- Instagramの画像URL
- 画像の重複排除
- 高解像度画像の選択
- Content Scriptの二重初期化
など実装して初めて分かった問題がかなり多かった。
そして、これらを通して一番感じたのは
SNSのDOMは「固定されたデータ構造」ではなく、「常に変化するもの」として扱ったほうがいい
ということだった。
Chrome拡張でSNSと連携する場合、DOMを直接扱う以上、SNS側の仕様変更との戦いは避けられない。
だからこそ
「今動くコードを書く」
だけではなく
「少しDOMが変わっても、できるだけ壊れないコードを書く」
ことが重要になる。
個人開発でSNS向けChrome拡張を作る場合、機能そのものよりも、この「SNS側の変化にどう付き合うか」が意外と大きなテーマになる。
本拡張機能も今後SNS側の仕様変更があるたびに修正することになると思う。
それも含めて個人開発ならではの面白さなのかもしれない。