4
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

簡易的なSite Checkerを作ろう! - SEO スコアリング編 -

4
Last updated at Posted at 2025-12-22

この記事は「簡易的なSite Checkerを作ろう!」シリーズの第2回です

  1. Playwright vs Cheerio、チューニング・工夫
  2. SEO スコアリング編(本記事)
  3. フロントエンド編 ─ Canvas でサイト構造を可視化(近日公開)

はじめに

こんにちは、マーズフラッグの小池です。
普段はフロントエンドエンジニアとして働いていますが、最近はバックエンドにも興味が出てきて、個人開発で挑戦しています。

前回の記事簡易的なSite Checkerを作ろう! - Playwright vs Cheerio、チューニングと工夫 -の続きです。

今回は、クローラで取得したサイト情報をもとに、SEO の観点でスコアリングしていく部分を書いていきます。

データベース設計

まず、SEO スコアリングに必要なデータ構造を考えました。
dbdiagram を使って一から設計しています。

全体像(簡易版)

┌─────────────┐     ┌───────────────┐     ┌─────────────────┐
│  projects   │────▶│ crawl_results │────▶│   crawl_data    │
└─────────────┘     └───────────────┘     └─────────────────┘
                           │
                           ▼
                    ┌─────────────────┐     ┌──────────────────┐
                    │seo_check_results│────▶│ seo_meta_details │
                    └─────────────────┘     └──────────────────┘
DBスキーマの詳細(クリックで展開)
// ログインユーザーのテーブル
Table profiles {
  id uuid [primary key, note: 'Supabase Auth UUID']
  name text
  avatar_url text
  created_at timestamp [default: 'now()']
  updated_at timestamp [default: 'now()']
}

/************************************
* プロジェクト
************************************/
Table projects {
  id uuid [primary key, default: 'gen_random_uuid()']
  user_id uuid [ref: > profiles.id, not null]
  name text [not null]
  description text
  site_url text [not null]
  max_pages integer [default: 100]
  is_active boolean [default: true]
  created_at timestamp [default: 'now()']
  updated_at timestamp [default: 'now()']
}

/************************************
* クローラ
************************************/
// クローラの設定
Table crawler_configs {
  id uuid [primary key, default: 'gen_random_uuid()']
  project_id uuid [ref: > projects.id, not null]
  max_pages integer
  max_depth integer [default: 3]
  exclude_patterns text[] [note: '[/admin/*, *.pdf]']
  include_patterns text[]
  follow_external_links boolean [default: false]
  respect_robots_txt boolean [default: true]
  delay_between_requests integer [default: 1000, note: 'ミリ秒']
  user_agent text [default: 'SiteChecker Bot 1.0']
  created_at timestamp [default: 'now()']
  updated_at timestamp [default: 'now()']
}

// projectから参照する結果
Table crawl_results {
  id uuid [primary key, default: 'gen_random_uuid()']
  project_id uuid [ref: > projects.id, not null]
  user_id uuid [ref: > profiles.id, not null]
  site_url text [not null]
  sitemap_data jsonb [note: 'VueFlow用のサイトマップデータ']
  status text [default: 'in_progress', note: 'in_progress, completed, failed']
  total_pages integer [default: 0]
  successful_pages integer [default: 0]
  failed_pages integer [default: 0]
  is_latest boolean [default: true]
  started_at timestamp [default: 'now()']
  completed_at timestamp
  created_at timestamp [default: 'now()']
  updated_at timestamp [default: 'now()']
}

// ページごとの履歴
// 同じ時にクロールしたものをまとめてcrawl_resultsにまとめる
Table crawl_data {
  id uuid [primary key, default: 'gen_random_uuid()']
  crawl_results_id uuid [ref: > crawl_results.id, not null]
  page_url text [not null]
  raw_html text [note: 'SEO分析に使用する生HTML']
  status_code integer
  error_message text
  created_at timestamp [default: 'now()']
}

// backendで使用。クロールジョブ
Table crawl_jobs {
  id uuid [primary key, default: 'gen_random_uuid()']
  user_id uuid [ref: > profiles.id, not null]
  project_id uuid [ref: > projects.id, not null]
  crawl_results_id uuid [ref: > crawl_results.id]
  status text [default: 'pending', note: 'pending, running, completed, failed, cancelled']
  progress integer [default: 0]
  error_message text
  max_pages integer
  started_at timestamp
  completed_at timestamp
  created_at timestamp [default: 'now()']
}

/************************************
* SEOチェック
************************************/
// SEOチェック結果サマリー
Table seo_check_results {
  id uuid [primary key, default: 'gen_random_uuid()']
  project_id uuid [ref: > projects.id, not null]
  crawl_results_id uuid [ref: > crawl_results.id, not null]
  total_score integer
  meta_score integer
  improvement_suggestions text
  checked_at timestamp [default: 'now()']
}

// メタデータのSEOチェック詳細
Table seo_meta_details {
  id uuid [primary key, default: 'gen_random_uuid()']
  seo_check_results_id uuid [ref: > seo_check_results.id, not null]
  page_url varchar(500) [not null]
  title_text varchar(255)
  title_length integer
  title_has_keywords boolean
  meta_description_text text
  meta_description_length integer
  canonical_url varchar(500)
  og_tags jsonb [note: '{og_title, og_description, og_image}']
  twitter_cards jsonb [note: '{twitter_title, twitter_description, twitter_image}']
  keywords text[]
  status_code integer
  score integer [note: '100点満点のSEOスコア']
  created_at timestamp [default: 'now()']
}

// SEOチェック設定
Table seo_check_configs {
  id uuid [primary key, default: 'gen_random_uuid()']
  project_id uuid [ref: > projects.id, not null]
  check_meta boolean [default: true]
  title_min_length integer [default: 30]
  title_max_length integer [default: 60]
  description_min_length integer [default: 120]
  description_max_length integer [default: 160]
  created_at timestamp [default: 'now()']
  updated_at timestamp [default: 'now()']
}

// backendで使用。SEOチェックジョブ
Table seo_check_jobs {
  id uuid [primary key, default: 'gen_random_uuid()']
  user_id uuid [ref: > profiles.id, not null]
  project_id uuid [ref: > projects.id, not null]
  crawl_results_id uuid [ref: > crawl_results.id]
  status text [default: 'pending', note: 'pending, running, completed, failed, cancelled']
  progress integer [default: 0]
  error_message text
  started_at timestamp
  completed_at timestamp
  created_at timestamp [default: 'now()']
}

悩んだポイント:crawl_results と crawl_data の分離

最初は1つのテーブルにまとめようと考えましたが、以下の理由で分離しました。

問題:raw_html の肥大化

crawl_data テーブルには SEO 分析用の生 HTML が入ります。
1ページあたり数十KB〜数百KBになることもあり、ページ数が増えるとテーブルが急速に肥大化します。

解決:役割で分離

テーブル 役割 データ量
crawl_results クロール結果のサマリー(画面表示用) 軽量
crawl_data ページごとの生データ(分析用) 重い
-- crawl_results: サマリー情報
Table crawl_results {
  id uuid [primary key]
  project_id uuid
  status text [note: 'in_progress, completed, failed']
  total_pages integer
  successful_pages integer
  failed_pages integer
  -- ...
}

-- crawl_data: ページごとの生データ
Table crawl_data {
  id uuid [primary key]
  crawl_results_id uuid [ref: > crawl_results.id]
  page_url text
  raw_html text [note: '← これが重い']
  status_code integer
  -- ...
}

こうすることで、画面表示時は軽量な crawl_results だけを取得し、SEO 分析時のみ crawl_data にアクセスする設計にできました。

SEO スコアリングのロジック

なぜ減点方式にしたか

スコアリング方式には「加点方式」と「減点方式」があります。

方式 考え方
加点方式 0点からスタート、良い点を加点
減点方式 100点からスタート、悪い点を減点

私は 減点方式 を採用しました。理由は2つあります。

1. 満点の定義が明確

加点方式だと「満点は何点なのか?」という問題に直面します。
チェック項目を増やすたびに満点が変わってしまい、過去のスコアと比較しづらくなります。

2. プロダクトの考え方にフィット

世に出すプロダクトは、本来100点満点であるべきです。
「ここが足りないから減点」という指摘の方が、改善ポイントが明確になると考えました。

チェック項目と配点

※ 以下の説明は私が考えた内容です。感覚で決めました。

7つの項目をチェックし、100点満点でスコアリングしています。

チェック項目 減点 減点条件
title タグ -20 未設定の場合
meta description -20 未設定の場合
OGP (og:title) -20 未設定の場合
Twitter Cards -20 twitter:title 未設定の場合
canonical URL -5 未設定の場合
meta keywords -5 未設定、またはタイトルに含まれていない場合
ステータスコード -10 200 以外の場合

配点の考え方:

  • title / description / OGP / Twitter Cards(各-20): SEO において最も重要な要素
  • canonical(-5): 重要だが、未設定でも致命的ではない
  • keywords(-5): 現在の SEO では重要度が下がっている
  • ステータスコード(-10): 200 以外は問題だが、リダイレクトの場合もある

実装

JSDOM で HTML をパース

クロール済みの raw_html を JSDOM でパースし、各要素を取得します。

import { JSDOM } from "jsdom";

// rawHTMLを解析してSEOメタ情報を抽出
for (const item of rawHTMLsAndPageUrl) {
  const { pageUrl, rawHTML } = item;
  const dom = new JSDOM(rawHTML);
  const doc = dom.window.document;

  // 各チェック関数を実行...
}

各チェック関数

実際に使用しているチェック関数を紹介します。

// title タグのチェック
async function checkMetaTitle(doc: Document) {
  const titleElement = doc.querySelector("title");
  const titleText = titleElement?.textContent || "";

  return {
    title_text: titleText,
    title_length: titleText.length,
    title_has_keywords: checkTitleHasMetaKeywords(doc, titleText),
  };
}

// タイトルに meta keywords が含まれているかチェック
function checkTitleHasMetaKeywords(doc: Document, title: string): boolean {
  const metaKeywords = doc
    .querySelector('meta[name="keywords"]')
    ?.getAttribute("content");
  if (!metaKeywords) return false;

  const keywords = metaKeywords.split(",").map((k) => k.trim().toLowerCase());
  const titleLower = title.toLowerCase();

  return keywords.some((keyword) => titleLower.includes(keyword));
}

// meta description のチェック
async function checkMetaDescription(doc: Document) {
  const metaDescription = doc.querySelector('meta[name="description"]');
  const descriptionText = metaDescription?.getAttribute("content") || "";

  return {
    meta_description_text: descriptionText,
    meta_description_length: descriptionText.length,
  };
}

// Canonical URL のチェック
async function checkCanonicalUrl(doc: Document) {
  const canonicalLink = doc.querySelector('link[rel="canonical"]');
  return {
    canonical_url: canonicalLink?.getAttribute("href") || "",
  };
}

// OGP のチェック
async function checkOpenGraphTags(doc: Document) {
  return {
    og_title: doc.querySelector('meta[property="og:title"]')?.getAttribute("content") || "",
    og_description: doc.querySelector('meta[property="og:description"]')?.getAttribute("content") || "",
    og_image: doc.querySelector('meta[property="og:image"]')?.getAttribute("content") || "",
  };
}

// Twitter Cards のチェック
async function checkTwitterCards(doc: Document) {
  return {
    twitter_cards: {
      twitter_title: doc.querySelector('meta[name="twitter:title"]')?.getAttribute("content") || "",
      twitter_description: doc.querySelector('meta[name="twitter:description"]')?.getAttribute("content") || "",
      twitter_image: doc.querySelector('meta[name="twitter:image"]')?.getAttribute("content") || "",
    },
  };
}

// keywords の抽出
function extractKeywords(doc: Document) {
  const metaKeywords = doc
    .querySelector('meta[name="keywords"]')
    ?.getAttribute("content");

  if (!metaKeywords) return { keywords: [] };

  const keywordList = metaKeywords
    .split(",")
    .map((keyword) => keyword.trim().toLowerCase())
    .filter((keyword) => keyword.length > 0);

  return { keywords: keywordList };
}

スコア計算

function calculateScore({
  titleCheckResult,
  descriptionCheckResult,
  canonicalUrlCheckResult,
  ogTagsCheckResult,
  twitterCardsCheckResult,
  keywordsCheckResult,
  statusCodeCheckResult,
}) {
  let score = 100;

  // タイトルのチェック
  if (!titleCheckResult.title_text) score -= 20;

  // デスクリプションのチェック
  if (!descriptionCheckResult.meta_description_text) score -= 20;

  // Canonical URL のチェック
  if (!canonicalUrlCheckResult.canonical_url) score -= 5;

  // OGP のチェック
  if (!ogTagsCheckResult.og_title) score -= 20;

  // Twitter Cards のチェック
  if (!twitterCardsCheckResult.twitter_cards.twitter_title) score -= 20;

  // キーワードのチェック
  if (keywordsCheckResult.keywords.length === 0) {
    score -= 5;
  } else {
    const titleLower = titleCheckResult.title_text.toLowerCase();
    const hasKeywords = keywordsCheckResult.keywords.some((keyword) =>
      titleLower.includes(keyword)
    );
    if (!hasKeywords) score -= 5;
  }

  // ステータスコードのチェック
  if (statusCodeCheckResult.status_code && statusCodeCheckResult.status_code !== 200) {
    score -= 10;
  }

  return Math.max(score, 0); // 0点以下にはならない
}

結果を Supabase に保存

各ページの SEO チェック結果は seo_meta_details テーブルに保存しています。

const { data, error } = await supabase
  .from("seo_meta_details")
  .insert({
    seo_check_results_id: seoCheckResultId,
    page_url: pageUrl,
    title_text: titleCheckResult.title_text,
    title_length: titleCheckResult.title_length,
    title_has_keywords: titleCheckResult.title_has_keywords,
    meta_description_text: descriptionCheckResult.meta_description_text,
    meta_description_length: descriptionCheckResult.meta_description_length,
    canonical_url: canonicalUrlCheckResult.canonical_url,
    og_tags: ogTagsCheckResult,
    twitter_cards: twitterCardsCheckResult.twitter_cards,
    keywords: keywordsCheckResult.keywords,
    status_code: statusCode,
    score: score,
  })
  .select()
  .single();

最後に、全ページのスコアを合算して平均を出し、seo_check_results テーブルに保存します。

// 平均スコアの計算
const totalScore = seoMetaResults.reduce((sum, result) => sum + result.score, 0);
const averageScore = Math.round(totalScore / seoMetaResults.length);

// seo_check_results に保存
await supabase
  .from("seo_check_results")
  .update({
    total_score: averageScore,
    meta_score: averageScore,
    improvement_suggestions: generateImprovementMessage(averageScore),
    checked_at: new Date().toISOString(),
  })
  .eq("id", seoCheckResultId);

クロール完了を Webhook で受け取る

クローラの処理が完了したら、自動的に SEO チェックを開始したいですよね。
これを実現するために Supabase Database Webhook を使用しました。

全体の流れ

┌─────────────────────────────────────────────────────────────────┐
│ crawler-backend                                                 │
│                                                                 │
│  crawl_results.status = "completed" に UPDATE                   │
└─────────────────────────────────────────────────────────────────┘
                          ↓
          Supabase Database Webhook(トリガー)
          UPDATE を検知 → ngrok URL に POST
                          ↓
┌─────────────────────────────────────────────────────────────────┐
│ ngrok(トンネル)                                                │
│                                                                 │
│  https://xxxx.ngrok.io → localhost:5050 に転送                  │
└─────────────────────────────────────────────────────────────────┘
                          ↓
┌─────────────────────────────────────────────────────────────────┐
│ seo-checker-backend (localhost:5050)                            │
│                                                                 │
│  POST /completed-crawler を受信                                 │
│  status === "completed" を確認                                  │
│  SEO チェックジョブをキューに追加                                │
└─────────────────────────────────────────────────────────────────┘

Supabase Dashboard での設定

Supabase のダッシュボードから Database Webhook を設定します。

設定場所: Supabase Dashboard → IntegrationsDatabase Webhooks

項目 設定値
Table crawl_results
Events UPDATE
HTTP Method POST
URL https://<ngrok-url>/completed-crawler

Webhook が発火すると、Supabase が以下のような JSON を POST します:

{
  "type": "UPDATE",
  "table": "crawl_results",
  "record": {
    "id": "uuid",
    "user_id": "uuid",
    "project_id": "uuid",
    "status": "completed",
    ...
  },
  "old_record": { ... }
}

なぜ Supabase Database Webhook を使うか

最初は crawler-backend から直接 seo-checker-backend を呼び出そうかと思いました。
でも、それだとサービス間が密結合になってしまいます。

Webhook を使うことで:

  • 疎結合: crawler-backend が seo-checker-backend の存在を知らなくてよい
  • 信頼性: Supabase 側でリトライ機能がある
  • 拡張性: 将来的に他のサービス(Slack通知など)も追加しやすい

という利点があります。

ローカル開発での課題:ngrok

ローカル開発時、Supabase から localhost:5050 に直接アクセスできません。
そこで ngrok を使ってトンネルを作成します。

ngrok に登録すると、固定のドメインを発行してもらえます。
以下のコマンドで起動すると、そのドメイン経由でローカルサーバーにアクセスできるようになります。

ngrok http --domain= 5050

起動後、発行されたドメイン(例: https://xxxx-xx-xx.ngrok-free.app)を Supabase の Webhook URL に設定すれば、ローカル環境でも Webhook を受け取れます。

Supabase Webhook
    ↓ POST
https://xxxx-xx-xx.ngrok-free.app/completed-crawler
    ↓ トンネル
localhost:5050/completed-crawler

Webhook エンドポイントの実装

app.post("/completed-crawler", async (req, res) => {
  const { record } = req.body;
  
  // status が completed の場合のみ処理
  if (record.status !== "completed") {
    return res.status(200).json({ message: "Skipped" });
  }
  
  // SEO チェックジョブをキューに追加
  await seoCheckQueue.addJob({
    userId: record.user_id,
    projectId: record.project_id,
    crawlResultDataId: record.id,
    seoCheckResultId: seoCheckResultId,
  });
  
  res.status(200).json({ message: "SEO check job queued" });
});

キュー管理の仕組み

SEO チェックは時間がかかる処理なので、キューで管理しています。
「Redis とか Bull 使った方がいいかな?」と思ったのですが、今回は簡易版ということで、Supabase テーブル + インメモリ管理のハイブリッド方式でシンプルに実装しました。

なぜ Redis を使わなかったか

正直、Redis + Bull を使いたい気持ちはありました。笑
でも、以下の理由から今回は見送りました:

  • インフラ構成をシンプルに保ちたかった
  • Supabase だけで完結させたかった
  • 同時実行数は1で十分だった

将来的にスケールが必要になったら Redis + Bull への移行を検討します。

状態管理

状態保存場所 用途
Supabase seo_check_jobs ジョブ一覧・履歴(永続化、UI表示)
インメモリ (isProcessing) 重複処理防止
インメモリ (processingJobs) 並行処理管理
class SupabaseQueue extends EventEmitter {
  private isProcessing = false;           // キュー処理中フラグ
  private concurrency = 1;                // 同時実行数(現在は1)
  private processingJobs = new Set<string>(); // 処理中のジョブID
}

ジョブのライフサイクル

pending → running → completed / failed
ステータス 意味
pending キュー待ち
running 処理中
completed 完了
failed 失敗

重複チェック(冪等性)

Webhook は複数回発火する可能性があります。
「同じジョブが二重に作成される」のは避けたかったので、2段階で重複チェックしています。

1. ジョブ追加時

async addJob(data) {
  // 重複チェック: 同じ crawl_results_id で既存のジョブがないか確認
  const { data: existingJob } = await supabase
    .from("seo_check_jobs")
    .select("id, status")
    .eq("crawl_results_id", data.crawlResultDataId)
    .eq("user_id", data.userId)
    .maybeSingle();

  // pending / running 状態のジョブがあればスキップ
  if (existingJob?.status === "pending" || existingJob?.status === "running") {
    console.log("既存のアクティブなジョブがあるため、新規作成をスキップ");
    return existingJob.id;
  }

  // 新規ジョブを追加
  const { data: job } = await supabase
    .from("seo_check_jobs")
    .insert([{ status: "pending", ... }])
    .select()
    .single();

  this.processQueue();
  return job.id;
}

2. キュー処理時

// 重複処理防止: 既に処理中でないかチェック
if (this.processingJobs.has(job.id)) {
  console.warn(`ジョブ ${job.id} は既に処理中です - スキップ`);
  continue;
}

// pending 状態の場合のみ running に更新
const { error } = await supabase
  .from("seo_check_jobs")
  .update({ status: "running", started_at: new Date().toISOString() })
  .eq("id", job.id)
  .eq("status", "pending"); // ← ここがポイント

これで、Webhook が複数回発火しても安心です。

キュー処理の流れ

┌─────────────────────────────────────────────────────┐
│ processQueue()                                      │
│                                                     │
│ 1. isProcessing チェック(重複起動防止)              │
│ 2. pending ジョブを1件取得(created_at昇順)         │
│ 3. status を running に更新                         │
│ 4. processJob() を非同期で実行                      │
└─────────────────────────────────────────────────────┘
          ↓
┌─────────────────────────────────────────────────────┐
│ processJob()                                        │
│                                                     │
│ 1. crawl_data をバッチ取得(10件ずつ)               │
│ 2. SEOチェック実行                                  │
│ 3. 結果を seo_check_results に保存                  │
│ 4. status を completed / failed に更新              │
│ 5. 1秒後に processQueue() を再呼び出し              │
└─────────────────────────────────────────────────────┘

crawl_data のバッチ取得

raw_html は1ページあたり数十KB〜数百KBになるので、一度に全件取得するとメモリを圧迫します。
そこで、Supabase の range() を使って10件ずつ取得しています。

const batchSize = 10;
const totalBatches = Math.ceil(totalRecords / batchSize);

for (let batchIndex = 0; batchIndex < totalBatches; batchIndex++) {
  const offset = batchIndex * batchSize;

  const { data, error } = await supabase
    .from("crawl_data")
    .select("*")
    .eq("crawl_results_id", job.crawl_results_id)
    .order("created_at", { ascending: true })
    .range(offset, offset + batchSize - 1);

  if (data && data.length > 0) {
    crawlData = crawlData.concat(data);
  }

  // バッチ処理の進行状況を更新
  const batchProgress = 10 + Math.floor(((batchIndex + 1) / totalBatches) * 30);
  await updateProgress(Math.min(batchProgress, 40));

  // API レート制限を避けるため、少し待機
  if (batchIndex < totalBatches - 1) {
    await new Promise((resolve) => setTimeout(resolve, 100));
  }
}

これで、100ページあっても10回に分けて処理できます。

進捗管理

UI にリアルタイムで進捗を表示するため、段階的に進捗を更新しています。

進捗 処理内容
10% 開始
10-40% crawl_data のバッチ取得
50% データ取得完了
60% SEO チェック実行完了
80% 結果保存中
100% 完了

こんな感じでチェックステータスとして出すことができます。supabaseのrealtimeを使用して、リアルタイムで終了を検知することも可能にしてみました。
Screenshot 2025-12-22 at 15.43.49.png

定期チェック(自己回復)

サーバーが再起動した場合、pending のまま残ったジョブがあるかもしれません。
そこで、30秒ごとに定期チェックを入れています。

startPeriodicCheck() {
  setInterval(() => {
    this.processQueue();
  }, 30000); // 30秒ごと
}

// サーバー起動時に定期チェック開始
seoCheckQueue.startPeriodicCheck();

これで、取りこぼしを防げます。

今後の拡張案

現状のロジックはシンプルなので、以下のような拡張を考えています。

拡張項目 内容
文字数チェック title は 30-60文字、description は 120-160文字が理想
h1 タグの重複チェック 1ページに複数の h1 があると SEO に悪影響
画像の alt 属性チェック alt 未設定の画像をリストアップ
内部リンク切れチェック 404 を返すリンクを検出
構造化データチェック JSON-LD の有無をチェック

まとめ

今回は、クロールしたデータを使った SEO スコアリングについて書きました。
実際にできた画面のスクショがこちらになります。
Screenshot 2025-12-22 at 15.26.59.png
Screenshot 2025-12-22 at 15.26.42.png
ogpの画像も確認できるようなUIにしてみたりしました。

学んだこと:

  • テーブル設計は「データの性質」と「アクセスパターン」を考慮する
  • 減点方式は「満点の定義が明確」「改善ポイントがわかりやすい」
  • Supabase Database Webhook でサービス間連携ができる
  • ngrok はローカル開発の強い味方
  • Redis 使わなくてもシンプルなキュー管理はできる

次回は、クロール結果を Canvas でサイト構造を可視化 する部分を書いていきます。


最後まで読んでいただきありがとうございました!

4
1
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
4
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?