minulo は、インスタのリール・YouTube のショート・TikTok のおすすめを隠し、DM や登録チャンネルは残す iPhone アプリです(隠すのは無料、Pro は有料)。SNS のウェブ版を WKWebView(react-native-webview)で開き、リールなどの住所へ移ろうとしたら止めて元へ戻しています。この記事は、minulo を1人で作っている北林が、その「止める」ときに本人が押したのかサイトが勝手に移したのかを見分ける仕組みについて書きます。
インスタを DM だけで使う方法は、利用者向けにこちらにまとめています:https://minulo.app/instagram-dms-only.html?ref=qiita-1104e
なぜ見分けるのか
リールの住所に来たときの扱いを、2通りに分けています。
- 本人が押して来た:止めた回数に数え、画面の上に4秒だけ帯を出して元のページへ戻す。
- サイトが勝手に移した(ログインしていない TikTok を開くとおすすめへ飛ばされる、自動再生で次の動画へ進む、など):回数に数えず、責めるような文も出さない。ただし見せないことは同じで、幕を出してページを about:blank に替え、動画と音を止める。
ここを取り違えると、ログインしていないだけの人に「止めました」が何度も出て記録も狂います。逆に本人が押した分を「勝手に移った」と扱うと、リールを押しても回数が残りません。
1. navigationType だけでは足りない
react-native-webview の onShouldStartLoadWithRequest には navigationType が来ます。リンクを押した移動なら click、サーバやスクリプトによる移動なら other です。最初はこれだけで分けていました。
const byUser = req.navigationType !== 'other';
これで足りなかった理由は2つあります。
-
SPA の中の移動は、そもそもこの関数を通りません。 Instagram や YouTube のモバイル版で下のタブを押しても、
history.pushStateで住所が変わるだけで、読み込みは起きません。 -
Facebook のモバイル版は逆に、押すとページごと読み込み直します。 その移動はスクリプト経由なので
otherで届き、本人が押したのに「勝手に移った」側へ入っていました。
2. 触ったことはページの側で取る
本人が画面に触ったという事実は、ページの中でしか取れません。読み込み後に入れる JS(src/inject.ts の afterLoad)で、pointerdown・touchstart・keydown を capture で拾い、アプリへ知らせています。
var actedSent = 0;
['pointerdown', 'touchstart', 'keydown'].forEach(function(ev){
window.addEventListener(ev, function(){
acted = true;
var now = Date.now();
if (now - actedSent > 500) { actedSent = now; try { window.ReactNativeWebView.postMessage(JSON.stringify({t:'acted', n:N})); } catch (e) { /* 何もしない */ } }
}, { capture: true, passive: true });
});
-
passive: trueにして、スクロールを妨げないようにしています。 - 送るのは0.5秒に1回までです。スクロール中は touchstart が続けて来るので、毎回送るとブリッジが混みます。
- 最初は「一度触ったら以後ずっと本人」としていましたが、それだとログイン直後にサイトが自動で移した分まで本人の引き返しに数えてしまいました。今は触るたびに送り、アプリ側で「最後に触った時刻」として持っています。
SPA の中の移動は、ページの側の見張り(止める住所に来たかを見る guard)が見つけてアプリへ知らせます。本人が押したかどうかは、アプリ側で次の時刻の窓を使って決めます。
3. アプリ側は「触ってから何秒以内か」で決める
アプリ側(app/view/[id].tsx)は、最後に触った時刻を1つの ref に持ちます。
const actedAt = useRef(0);
/* 8秒:押してから通信を待って移るサービス(m.youtube・m.facebook)も本人の移動として拾う */
const actedRecently = () => Date.now() - actedAt.current < 8000;
onShouldStartLoadWithRequest の側では、navigationType と合わせて判定します。
if (req.navigationType !== 'other') actedAt.current = Date.now();
// ...
const byUser = req.navigationType !== 'other' || actedRecently();
窓の長さは一度直しています。はじめ2.5秒にしたところ、押してから通信を待って移る m.youtube.com や m.facebook.com では間に合わず、本人が押したのに「勝手に開いた」側の幕が出ました。今は8秒です。長くするほど「押した直後にサイトが自分で移した分」も本人扱いになるので、短くしすぎず長くしすぎずの調整になります。
4. ログイン直後だけは別に扱う
ログインの画面で[ログイン]を押すと、その操作が8秒の窓に入ります。直後にサイトが自動でおすすめの画面へ移すと、本人が押したものとして数えてしまっていました。
そこで、ログインの画面を出てから30秒以内で、かつその後に触っていない移動は、止めるのではなく入口(そのサービスで最初に開く画面)への置き換えにしています。
const autoAfterLogin = () => Date.now() - leftLoginAt.current < 30000 && actedAt.current <= leftLoginAt.current;
置き換えは1回のログインにつき1度だけです。置き換え先がまた見張りに当たると、30秒のあいだ入口とおすすめを往復したことがあったためです。
5. ページから来た知らせを信じすぎない
acted はページの JS が送る値なので、だれでも送れます。受け取る側では次の条件を付けています。
- SNS の画面を開くたびに作る合言葉(nonce)が合わない知らせは捨てる。
- 送ってきたページの住所が、そのサービスかログインの道のホストでなければ捨てる。
- ログインの道(Google や Apple のページ)から受けるのは、ログインの知らせと
actedだけ。止めた知らせや効き目の報告は、サービス自身のページからしか受けない。
止めた知らせは、アプリ側でもう一度住所を正規表現に当てて、当たっていなければ「止めた」とは扱いません。
できないこと
- 判定は時刻の窓なので、押した直後にサイトが自分で移した分は本人の移動に入ります。完全には分けられません。
- 見分けられるのは minulo の中で開いた SNS だけです。公式アプリの中は変えられないので、公式アプリはスクリーンタイムでまるごと止める別の仕組みにしています。
コードは minulo の src/inject.ts と app/view/[id].tsx から短く抜いています。同じように WKWebView でほかのサイトを開いて一部を止めるアプリを作る方の参考になればうれしいです。