0
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?

概要

業務でGA4を触る機会があり、Web計測技術がどのような仕組みになっているかきちんと理解したいと思ったので、簡易的に実装して勉強してみました。

sample.gif

Claude/Claude Code等を使用して調査や勉強・実装まで試してみています。記事は人力で書きました。

使用した技術

なるべく簡単に作りたかったので、Google Apps Script(以降GAS)とGoogleスプレッドシートをバックエンド・データベースとして試してみました。GASのコードはPOSTリクエストを受け付けてスプレッドシートにデータを追記するだけの単純なものになります。

フロントエンド側、いわゆるGAタグにあたるものはJavaScript1ファイルで実装し、サンプルで用意したHTMLファイルに読み込ませて使用しています。

実装内容

バックエンド

前述のように、今回はGASとスプレッドシートで実装してみます。スプレッドシートを開いて[拡張機能] → [Apps Script]からGASエディタを開いてください。

作るものは非常に単純で、フロント側からイベントごとにPOSTリクエストを受け取りスプレッドシートに追記します。
なお、事前にスプレッドシートにはAnalyticsというシートを作成してください。

code.gs
function doPost(e) {
    try {
        const data = JSON.parse(e.postData.contents);
        const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('Analytics');

        sheet.appendRow([
            new Date(),
            data.userId,
            data.sessionId,
            data.eventType,
            data.eventName,
            data.url,
            data.referrer,
            data.userAgent,
            data.screenSize,
            JSON.stringify(data.params || {})
        ]);
        return ContentService
            .createTextOutput(JSON.stringify({ success: true }))
            .setMimeType(ContentService.MimeType.JSON);
    }
    catch (error) {
        Logger.log(error);
        return ContentService
            .createTextOutput(JSON.stringify({ success: false, error: error.toString() }))
            .setMimeType(ContentService.MimeType.JSON);
    }
}

GASはこれだけです。
Webアプリとしてアクセスできるユーザーを「全員」としてデプロイし、URLを控えておきましょう。

フロントエンド

JavaScriptファイル(tracking.js)を1つ作成します。作成したプログラム全ては以下を展開してご確認ください。
CONFIGendpointに先ほど控えておいたGASのURLを設定します。

tracking.js
tracking.js
(function () {
  'use strict';

  const CONFIG = {
    endpoint: '<GASのURLを設定してください>',
    cookieName: 'analytics_uid',
    cookieExpireDays: 365,
    // ファイルダウンロード計測対象の拡張子
    downloadExtensions: ['pdf', 'zip', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'csv', 'txt', 'dmg', 'exe', 'apk', 'rar', '7z', 'tar', 'gz']
  };

  // ============================================
  // グローバル関数: demoAnalytics
  // ============================================
  window.demoAnalytics = function (eventType, eventName, params) {
    const data = {
      eventType: eventType,      // 'track', 'page', 'event' など
      eventName: eventName,       // 'PageView', 'Purchase', 'Click' など
      params: params || {},       // カスタムパラメータ
      // 共通データ
      url: window.location.href,
      referrer: document.referrer,
      timestamp: new Date().toISOString(),
      userId: getUserId(),
      sessionId: getSessionId(),
      userAgent: navigator.userAgent,
      screenSize: `${window.screen.width}x${window.screen.height}`,
      viewport: `${window.innerWidth}x${window.innerHeight}`
    };

    navigator.sendBeacon(CONFIG.endpoint, JSON.stringify(data));
  };

  // ============================================
  // 初期化:ページビュー自動計測
  // ============================================
  function init() {
    // ページビュー計測
    demoAnalytics('page', 'PageView', {
      title: document.title,
      path: window.location.pathname
    });

    // ページ離脱時の計測
    window.addEventListener('beforeunload', function () {
      demoAnalytics('track', 'PageExit', {
        timeOnPage: Math.round((Date.now() - pageLoadTime) / 1000)
      });
    });
  }

  const pageLoadTime = Date.now();

  // ============================================
  // ユーザーID管理(Cookie)
  // ============================================
  function getUserId() {
    let userId = getCookie(CONFIG.cookieName);
    if (!userId) {
      userId = 'uid_' + generateRandomId();
      setCookie(CONFIG.cookieName, userId, CONFIG.cookieExpireDays);
    }
    return userId;
  }

  // ============================================
  // セッションID管理(SessionStorage)
  // ============================================
  function getSessionId() {
    let sessionId = sessionStorage.getItem('analytics_sid');
    if (!sessionId) {
      sessionId = 'sid_' + generateRandomId();
      sessionStorage.setItem('analytics_sid', sessionId);
    }
    return sessionId;
  }

  // ============================================
  // ユーティリティ関数
  // ============================================
  function generateRandomId() {
    return Math.random().toString(36).substr(2, 9) + Date.now().toString(36);
  }

  function getCookie(name) {
    const value = `; ${document.cookie}`;
    const parts = value.split(`; ${name}=`);
    if (parts.length === 2) return parts.pop().split(';').shift();
  }

  function setCookie(name, value, days) {
    const expires = new Date(Date.now() + days * 864e5).toUTCString();
    document.cookie = `${name}=${value}; expires=${expires}; path=/; SameSite=Lax`;
  }

  // 初期化実行
  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', init);
    document.addEventListener('DOMContentLoaded', initEnhancedTracking);
  } else {
    init();
    initEnhancedTracking();
  }

  // スクロール深度計測
  let maxScrollDepth = 0;
  window.addEventListener('scroll', function () {
    const scrollDepth = Math.round(
      (window.scrollY / (document.body.scrollHeight - window.innerHeight)) * 100
    );

    if (scrollDepth > maxScrollDepth) {
      maxScrollDepth = scrollDepth;

      // 25%, 50%, 75%, 100% で送信
      if ([25, 50, 75, 100].includes(scrollDepth)) {
        demoAnalytics('event', 'Scroll', {
          depth: scrollDepth
        });
      }
    }
  });

  // ============================================
  // ファイルダウンロード & 外部リンククリック計測
  // ============================================
  document.addEventListener('click', function (e) {
    const link = e.target.closest('a');
    if (!link || !link.href) return;

    // ファイルダウンロード計測
    const fileExtension = link.href.split('?')[0].split('.').pop().toLowerCase();
    if (CONFIG.downloadExtensions.includes(fileExtension)) {
      demoAnalytics('event', 'FileDownload', {
        fileName: link.href.split('/').pop().split('?')[0],
        fileExtension: fileExtension,
        fileUrl: link.href,
        linkText: link.textContent.trim()
      });
      return; // ファイルダウンロードの場合は外部リンククリックとして計測しない
    }

    // 外部リンククリック計測
    if (link.hostname && link.hostname !== window.location.hostname) {
      demoAnalytics('event', 'OutboundClick', {
        url: link.href,
        text: link.textContent.trim(),
        destination: link.hostname
      });
    }
  });

  // ============================================
  // エラー計測
  // ============================================
  window.addEventListener('error', function (e) {
    demoAnalytics('event', 'JavaScriptError', {
      message: e.message,
      filename: e.filename,
      line: e.lineno
    });
  });

  // ============================================
  // 拡張計測機能の初期化
  // ============================================
  function initEnhancedTracking() {
    // HTML5動画エンゲージメント計測
    initVideoTracking();

    // フォーム操作計測
    initFormTracking();

    // YouTube埋め込み動画計測(YouTube IFrame APIが利用可能な場合)
    initYouTubeTracking();
  }

  // ============================================
  // HTML5動画エンゲージメント計測
  // ============================================
  function initVideoTracking() {
    const videos = document.querySelectorAll('video');

    videos.forEach(function(video, index) {
      const videoId = video.id || `video-${index}`;
      const videoSrc = video.currentSrc || video.querySelector('source')?.src || '';
      let hasStarted = false;

      // 再生開始
      video.addEventListener('play', function() {
        if (!hasStarted) {
          demoAnalytics('event', 'VideoStart', {
            videoId: videoId,
            videoUrl: videoSrc,
            videoTitle: video.title || videoSrc.split('/').pop()
          });
          hasStarted = true;
        } else {
          demoAnalytics('event', 'VideoPlay', {
            videoId: videoId,
            currentTime: Math.round(video.currentTime)
          });
        }
      });

      // 一時停止
      video.addEventListener('pause', function() {
        if (!video.ended) {
          demoAnalytics('event', 'VideoPause', {
            videoId: videoId,
            currentTime: Math.round(video.currentTime),
            percentageWatched: Math.round((video.currentTime / video.duration) * 100)
          });
        }
      });

      // 視聴完了
      video.addEventListener('ended', function() {
        demoAnalytics('event', 'VideoComplete', {
          videoId: videoId,
          duration: Math.round(video.duration)
        });
      });

      // 進捗計測(25%, 50%, 75%)
      const progressMarkers = { 25: false, 50: false, 75: false };
      video.addEventListener('timeupdate', function() {
        const percentage = Math.round((video.currentTime / video.duration) * 100);

        [25, 50, 75].forEach(function(marker) {
          if (percentage >= marker && !progressMarkers[marker]) {
            progressMarkers[marker] = true;
            demoAnalytics('event', 'VideoProgress', {
              videoId: videoId,
              progress: marker
            });
          }
        });
      });
    });
  }

  // ============================================
  // YouTube埋め込み動画計測
  // ============================================
  function initYouTubeTracking() {
    // YouTube IFrame APIが読み込まれているかチェック
    if (typeof YT === 'undefined' || typeof YT.Player === 'undefined') {
      return; // APIが利用できない場合はスキップ
    }

    const iframes = document.querySelectorAll('iframe[src*="youtube.com"], iframe[src*="youtu.be"]');

    iframes.forEach(function(iframe, index) {
      const player = new YT.Player(iframe, {
        events: {
          'onStateChange': function(event) {
            const videoData = player.getVideoData();
            const videoId = videoData.video_id;

            if (event.data === YT.PlayerState.PLAYING) {
              demoAnalytics('event', 'YouTubePlay', {
                videoId: videoId,
                videoTitle: videoData.title
              });
            } else if (event.data === YT.PlayerState.PAUSED) {
              demoAnalytics('event', 'YouTubePause', {
                videoId: videoId,
                currentTime: Math.round(player.getCurrentTime())
              });
            } else if (event.data === YT.PlayerState.ENDED) {
              demoAnalytics('event', 'YouTubeComplete', {
                videoId: videoId
              });
            }
          }
        }
      });
    });
  }

  // ============================================
  // フォーム操作計測
  // ============================================
  const trackedForms = new WeakMap();

  function initFormTracking() {
    const forms = document.querySelectorAll('form');

    forms.forEach(function(form, index) {
      const formId = form.id || form.name || `form-${index}`;

      // フォームへの最初の入力を計測(フォーカスまたは入力開始)
      const formInputs = form.querySelectorAll('input, textarea, select');
      formInputs.forEach(function(input) {
        input.addEventListener('focus', function() {
          if (!trackedForms.get(form)) {
            trackedForms.set(form, true);
            demoAnalytics('event', 'FormStart', {
              formId: formId,
              formName: form.name || formId,
              firstField: input.name || input.id || input.type
            });
          }
        }, { once: true });
      });

      // フォーム送信計測
      form.addEventListener('submit', function(e) {
        const formData = new FormData(form);
        const searchQuery = formData.get('q') || formData.get('search') || formData.get('query') || formData.get('s');

        // サイト内検索の検出
        if (searchQuery) {
          demoAnalytics('event', 'SiteSearch', {
            searchQuery: searchQuery,
            formId: formId
          });
        } else {
          // 通常のフォーム送信
          demoAnalytics('event', 'FormSubmit', {
            formId: formId,
            formName: form.name || formId,
            formAction: form.action,
            formMethod: form.method
          });
        }
      });
    });
  }

})();

主要な機能をいくつか簡単に解説します。

demoAnalytics関数

  window.demoAnalytics = function (eventType, eventName, params) {
    const data = {
      eventType: eventType,      // 'track', 'page', 'event' など
      eventName: eventName,       // 'PageView', 'Purchase', 'Click' など
      params: params || {},       // カスタムパラメータ
      // 共通データ
      url: window.location.href,
      referrer: document.referrer,
      timestamp: new Date().toISOString(),
      userId: getUserId(),
      sessionId: getSessionId(),
      userAgent: navigator.userAgent,
      screenSize: `${window.screen.width}x${window.screen.height}`,
      viewport: `${window.innerWidth}x${window.innerHeight}`
    };

    navigator.sendBeacon(CONFIG.endpoint, JSON.stringify(data));
  };

ここでグローバル関数demoAnalyticsを定義しています。この仕組みの肝となる関数です。
tracking.js内でもイベントを送信するために何度も使用していますが、サンプルHTMLからイベントを送信したい場合にも使用するためグローバル関数として定義しています。

eventType eventName paramsについては引数として受け取ったものをそのままdata変数に設定しています。
それ以外にも共通データとしてreferreruserAgentなど、document・navigatorオブジェクトなどから取得して設定しています。

最後に以下の一文でGAS側にdata変数の内容を送っています。

navigator.sendBeacon(CONFIG.endpoint, JSON.stringify(data));

sendBeacon() メソッドを使用するのがポイントで、今回のような計測データを送るために適したAPIとなっています。以下、こちらの引用です。

sendBeacon() メソッドでは、ユーザーエージェントがその機会を持ったときに、アンロードや次のナビゲーションを遅らせることなく、非同期にデータを送信します。つまり、

  • データは確実に送信されます。
  • 非同期に送信されます。
  • 次のページの読み込みには影響しません

データは HTTP POST リクエストで送信されます。

init関数(初期化)

ページビューの計測と、ページ離脱時の計測を行なっています。先ほど作成したdemoAnalytics関数を使用して適切なタイミングでイベントが送信されるようにします。

  function init() {
    // ページビュー計測
    demoAnalytics('page', 'PageView', {
      title: document.title,
      path: window.location.pathname
    });

    // ページ離脱時の計測
    window.addEventListener('beforeunload', function () {
      demoAnalytics('track', 'PageExit', {
        timeOnPage: Math.round((Date.now() - pageLoadTime) / 1000)
      });
    });
  }

スクロール深度計測

スクロールが25%, 50%, 75%, 100%となった時に自動的にイベント送信されるようにします。

  // スクロール深度計測
  let maxScrollDepth = 0;
  window.addEventListener('scroll', function () {
    const scrollDepth = Math.round(
      (window.scrollY / (document.body.scrollHeight - window.innerHeight)) * 100
    );

    if (scrollDepth > maxScrollDepth) {
      maxScrollDepth = scrollDepth;

      // 25%, 50%, 75%, 100% で送信
      if ([25, 50, 75, 100].includes(scrollDepth)) {
        demoAnalytics('event', 'Scroll', {
          depth: scrollDepth
        });
      }
    }
  });

ファイルダウンロード & 外部リンククリック計測

aタグクリックの際に、CONFIGdownloadExtensionsで設定している拡張子が含まれている場合はファイルダウンロードとしてイベント送信をしています。
また、リンクのホスト名が現在のURLのホスト名と異なる場合、外部リンクとしてイベントを送信しています。

  document.addEventListener('click', function (e) {
    const link = e.target.closest('a');
    if (!link || !link.href) return;

    // ファイルダウンロード計測
    const fileExtension = link.href.split('?')[0].split('.').pop().toLowerCase();
    if (CONFIG.downloadExtensions.includes(fileExtension)) {
      demoAnalytics('event', 'FileDownload', {
        fileName: link.href.split('/').pop().split('?')[0],
        fileExtension: fileExtension,
        fileUrl: link.href,
        linkText: link.textContent.trim()
      });
      return; // ファイルダウンロードの場合は外部リンククリックとして計測しない
    }

    // 外部リンククリック計測
    if (link.hostname && link.hostname !== window.location.hostname) {
      demoAnalytics('event', 'OutboundClick', {
        url: link.href,
        text: link.textContent.trim(),
        destination: link.hostname
      });
    }
  });

その他

その他、JavaScriptエラーや動画エンゲージメント計測、フォーム操作計測なども計測するようにしてみていますので、詳しくはに記述したtracking.jsの内容をご確認ください。

サンプルHTML

ここまでの実装で計測する機能自体はできていますので、最後に適当なHTMLファイルを作成してうまくデータが取得できるか確認してみます。
基本的な計測はtracking.jsを読み込むだけです。カスタムのイベントを設定したい場合にはdemoAnalyticsを使用して任意のタイミングでイベントを送信します。

index.html
<!DOCTYPE html>
<html lang="ja">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Web計測デモサイト - イベントトラッキングサンプル</title>
  <script src="./tracking.js"></script>
  <style>
    body {
      font-family: sans-serif;
      max-width: 800px;
      margin: 0 auto;
      padding: 20px;
      line-height: 1.6;
    }

    section {
      margin: 40px 0;
      padding: 20px;
      border: 1px solid #ddd;
    }

    h2 {
      margin-top: 0;
    }

    button {
      margin: 5px;
      padding: 10px 15px;
      cursor: pointer;
    }

    input,
    textarea {
      margin: 5px 0;
      padding: 8px;
      width: 100%;
      box-sizing: border-box;
    }

    .tabs button {
      background: #f0f0f0;
      border: 1px solid #ccc;
    }

    .tabs button.active {
      background: #fff;
      border-bottom: 1px solid #fff;
    }

    .tab-content {
      display: none;
      padding: 20px;
      border: 1px solid #ccc;
    }

    .tab-content.active {
      display: block;
    }

    footer {
      margin-top: 50px;
      padding: 20px;
      background: #f0f0f0;
      text-align: center;
    }
  </style>
</head>

<body>

  <h1>Web計測デモサイト</h1>
  <p>このページでは様々なユーザー行動を計測できます。GA4の拡張計測機能を自前で実装したサンプルです。</p>

  <!-- セクション1: ボタンクリック計測 -->
  <section>
    <h2>1. ボタンクリック計測</h2>
    <p>カスタムイベントを手動で送信する例です。</p>

    <button onclick="demoAnalytics('event', 'ButtonClick', {
      buttonName: 'Add to Cart',
      productId: 'PROD-123',
      productName: 'サンプル商品A',
      price: 1980
    })">
      カートに追加
    </button>

    <button onclick="demoAnalytics('event', 'ButtonClick', {
      buttonName: 'Purchase',
      productId: 'PROD-123',
      totalAmount: 1980
    })">
      購入する
    </button>

    <button onclick="demoAnalytics('event', 'Share', {
      platform: 'twitter',
      contentId: 'article-001'
    })">
      Twitterでシェア
    </button>
  </section>

  <!-- セクション2: ファイルダウンロード計測(自動) -->
  <section>
    <h2>2. ファイルダウンロード計測(自動)</h2>
    <p>ファイルへのリンククリックを自動で計測します。PDF、ZIP、Office文書など17種類の拡張子に対応。</p>

    <p><a href="./files/sample.pdf">サンプルPDFをダウンロード</a></p>
    <p><small>※ その他の拡張子: .zip, .docx, .xlsx, .pptx, .csv, .txt, .exe, .dmg, .apk など</small></p>
  </section>

  <!-- セクション3: フォーム計測(自動) -->
  <section>
    <h2>3. フォーム計測(自動)</h2>
    <p>フォームへの最初の入力(FormStart)と送信(FormSubmit)を自動計測します。</p>

    <h3>お問い合わせフォーム</h3>
    <form id="contact-form" onsubmit="event.preventDefault(); alert('送信されました(デモ)');">
      <input type="text" name="name" placeholder="お名前" required>
      <input type="email" name="email" placeholder="メールアドレス" required>
      <textarea name="message" placeholder="お問い合わせ内容" rows="4" required></textarea>
      <button type="submit">送信</button>
    </form>
    <p><small>※ 入力を始めると「FormStart」、送信すると「FormSubmit」が自動で記録されます</small></p>
  </section>

  <!-- セクション4: リンククリック計測 -->
  <section>
    <h2>4. リンククリック計測</h2>
    <p>外部リンクのクリックは自動計測されます。</p>

    <p><a href="#section-tabs" onclick="demoAnalytics('event', 'InternalLinkClick', {
      linkText: 'タブセクションへ',
      destination: '#section-tabs'
    })">タブセクションへ移動(ページ内リンク - 手動計測)</a></p>

    <p><a href="https://www.google.com" target="_blank">Googleへ(外部リンク - 自動計測)</a></p>
  </section>

  <!-- セクション5: タブ切り替え -->
  <section id="section-tabs">
    <h2>5. タブ切り替え計測</h2>
    <div class="tabs">
      <button class="tab-btn active" onclick="switchTab(event, 'tab1', 'タブ1')">タブ1</button>
      <button class="tab-btn" onclick="switchTab(event, 'tab2', 'タブ2')">タブ2</button>
      <button class="tab-btn" onclick="switchTab(event, 'tab3', 'タブ3')">タブ3</button>
    </div>

    <div id="tab1" class="tab-content active">
      <h3>タブ1のコンテンツ</h3>
      <p>これはタブ1の内容です。</p>
    </div>
    <div id="tab2" class="tab-content">
      <h3>タブ2のコンテンツ</h3>
      <p>これはタブ2の内容です。</p>
    </div>
    <div id="tab3" class="tab-content">
      <h3>タブ3のコンテンツ</h3>
      <p>これはタブ3の内容です。</p>
    </div>
  </section>

  <!-- セクション6: エラー計測 -->
  <section>
    <h2>6. JavaScriptエラー計測(自動)</h2>
    <p>意図的にエラーを発生させて、エラートラッキングをテストします。</p>
    <button onclick="throw new Error('テスト用エラー')">エラーを発生させる</button>
    <p><small>※ JavaScriptエラーは自動的に記録されます</small></p>
  </section>

  <!-- セクション7: カスタムイベント -->
  <section>
    <h2>7. カスタムイベント計測</h2>
    <p>任意のカスタムイベントを送信できます。</p>

    <button onclick="demoAnalytics('event', 'CustomEvent', {
      category: 'engagement',
      action: 'custom_action',
      label: 'user_engagement',
      value: 100
    })">
      カスタムイベント送信
    </button>

    <button onclick="demoAnalytics('track', 'Conversion', {
      conversionType: 'signup',
      conversionValue: 5000,
      currency: 'JPY'
    })">
      コンバージョン計測
    </button>
  </section>

  <footer>
    <h3>自動計測されるイベント</h3>
    <p>
      <strong>ページビュー(PageView)</strong> - ページ読み込み時<br>
      <strong>スクロール深度(Scroll)</strong> - 25%, 50%, 75%, 100%<br>
      <strong>外部リンククリック(OutboundClick)</strong> - 外部サイトへのリンク<br>
      <strong>ファイルダウンロード(FileDownload)</strong> - PDF/ZIP/Office等17種<br>
      <strong>フォーム操作(FormStart/FormSubmit)</strong> - 入力開始と送信<br>
      <strong>サイト内検索(SiteSearch)</strong> - 検索フォーム送信<br>
      <strong>JavaScriptエラー(JavaScriptError)</strong> - エラー発生時<br>
      <strong>ページ離脱(PageExit)</strong> - ページを閉じる時
    </p>
    <p style="margin-top: 20px;">Web計測デモサイト - すべてのイベントはGoogle Spreadsheetに記録されます</p>
    <p><small>※ tracking.jsには動画エンゲージメント計測機能も実装されています(HTML5動画・YouTube動画対応)</small></p>
  </footer>

  <script>
    // タブ切り替え関数
    function switchTab(event, tabId, tabName) {
      // すべてのタブコンテンツを非表示
      document.querySelectorAll('.tab-content').forEach(content => {
        content.classList.remove('active');
      });

      // すべてのタブボタンを非アクティブ
      document.querySelectorAll('.tab-btn').forEach(btn => {
        btn.classList.remove('active');
      });

      // 選択されたタブを表示
      document.getElementById(tabId).classList.add('active');
      event.target.classList.add('active');

      // タブ切り替えイベント送信
      demoAnalytics('event', 'TabSwitch', {
        tabId: tabId,
        tabName: tabName
      });
    }
  </script>

</body>

</html>

このHTMLファイルをブラウザで開いていろいろ触ってみると、スプレッドシートにイベントが登録されていきます。

sample.gif

まとめ

GAS/JavaScriptを使用してGAのようにイベントを計測するための技術を学んでみました。
思ったよりシンプルな仕組みで実装できると感じたところもありつつ、sendBeacon() メソッドのような普段触ったことがなかったAPIや仕組みに触れることができて面白かったです。

最後までご覧いただきありがとうございました。

0
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
0
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?