Closed58

Zennで自分のスクラップ記録を集計する拡張機能を作りたいかもしれない

hajimismhajimism

スクラップにあらゆる勉強を寄せているので、スクラップを見れば毎月の振り返りができる。

  • 今月Closeされたスクラップは何本?
  • スクラップ1本あたりの平均コメント数は?

程度の集計を拡張機能からサクッとできたら嬉しそう

hajimismhajimism

https://zenn.dev/api/scraps?username={username}で、ユーザーごとにすべてのスクラップを取ってこれるっぽい

hajimismhajimism

JSONから型を生成させたらこんな感じ

export interface Scrap {
  scraps?: (ScrapsEntity)[] | null;
  next_page?: null;
}
export interface ScrapsEntity {
  id: number;
  post_type: string;
  user_id: number;
  slug: string;
  title: string;
  closed: boolean;
  closed_at?: string | null;
  archived: boolean;
  liked_count: number;
  can_others_post: boolean;
  comments_count: number;
  created_at: string;
  last_comment_created_at: string;
  should_noindex: boolean;
  path: string;
  topics?: (TopicsEntity)[] | null;
  user: User;
}
export interface TopicsEntity {
  id: number;
  name: string;
  display_name: string;
  taggings_count: number;
  image_url?: string | null;
}
export interface User {
  id: number;
  username: string;
  name: string;
  avatar_small_url: string;
}

hajimismhajimism

100件くらい超えたらページネーションあるんかもしれない。どうだろ

hajimismhajimism

イメージ

  • archiveされてないものを全体とする
  • デフォルトで総本数、総コメント数、平均コメント数を計測
  • カレンダー的なUIで対象日にfilterをかけられる
  • それとは別によく使いそうなfilterをボタンで用意
    • 直近1, 2, 3, 12ヶ月
    • 今年
    • 今月
hajimismhajimism

React*TSで拡張機能作りたい

hajimismhajimism

上の記事の参考資料にあった

https://qiita.com/RyBB/items/32b2a7b879f21b3edefc

JavaScriptファイル (実際に動作させるやつ)
HTMLファイル (設定画面等を使いたい場合)
CSSファイル (デザイン用)
Imageファイル (Chrome拡張のアイコンとか)
manifestファイル (設定ファイル)
の2つがあれば、とりあえず最低限のものはできます!手が出しやすい!!

hajimismhajimism

一旦開発者ツールのコンソールでやってみるか

hajimismhajimism

雑にこんな感じ

const data = await fetch('https://zenn.dev/api/scraps?username=ikenohi').then(res => res.json())

const allActiveScrap = data.scraps.filter(item => !item.archived)

const sum = (a,b) => a+b

const totalScraps = allActiveScrap.length
const totalComments = allActiveScrap.map(item => item.comments_count).reduce(sum,0)

const message = `総スクラップ数:${totalScraps}, 総コメント数:${totalComments}`
→総スクラップ数:21, 総コメント数:710
hajimismhajimism
const avarageComments = totalComments / totalScraps
33.80952380952381

十分だな

hajimismhajimism

これのあとに

const data = await fetch('https://zenn.dev/api/scraps?username=ikenohi').then(res => res.json())

const allActiveScrap = data.scraps.filter(item => !item.archived)

const sum = (a,b) => a+b

const totalScraps = allActiveScrap.length
const totalComments = allActiveScrap.map(item => item.comments_count).reduce(sum,0)
const avarageComments = Math.round(totalComments / totalScraps)

const msg = `総スクラップ数:${totalScraps} \n総コメント数:${totalComments} \n平均コメント数:${avarageComments}`

これをすればやりたいことは大体できる

copy(msg)
hajimismhajimism

あとは日付のフィルター
そういえばJSの日付のフォーマット雰囲気で使ってるからあとで別のスクラップ立てて調べよう

hajimismhajimism

月末と月初の取得はこんな感じ?なんもわからん

const startOfMonth = (month) => {
    const date = new Date()

    if (month) date.setMonth(month - 1)
    date.setDate(1)
    date.setHours(0)
    date.setMinutes(0)
    date.setSeconds(0)
    
    return date
}

const endOfMonth = (month) => {
    const date = new Date()
    
    if (month) {
        date.setMonth(month)
    } else {
        date.setMonth(date.getMonth() + 1)
    }
    
    date.setDate(0)
    date.setHours(23)
    date.setMinutes(59)
    date.setSeconds(59)
    
    return date
}
hajimismhajimism
const endOfMonth = (month, year) => {
    const date = new Date()

    if (year) date.setYear(year)
    
    if (month) {
        date.setMonth(month)
    } else {
        date.setMonth(date.getMonth() + 1)
    }
    
    date.setDate(0)
    date.setHours(23)
    date.setMinutes(59)
    date.setSeconds(59)
    
    return date
}
hajimismhajimism

いや、-2とかで書けるらしいからこうしよう

const endOfMonth = (month) => {
    const date = new Date()
    
    if (month) {
        date.setMonth(month + 1)
    } else {
        date.setMonth(date.getMonth() + 1)
    }
    
    date.setDate(0)
    date.setHours(23)
    date.setMinutes(59)
    date.setSeconds(59)
    
    return date
}

こうすればこうかけるはず

  • n月中:e(-1) < target < e()
  • 直近nヶ月:e(-n) < target < now
hajimismhajimism

直近nヶ月はeつかっちゃだめか
ふつうにnow.setMonth(-n)と比べればいいんだ

hajimismhajimism
const isThisMonth = (target) => {
    return target && endOfMonth(-1) < target && target < endOfMonth()
}

const nMonthBefore = (month) => {
    const date = new Date()
    date.setMonth(-month)
    date.setHours(0)
    date.setMinutes(0)
    date.setSeconds(0)

    return date
}

const isLastNMonth = (target, n) => {
    const l = n ===  undefined ? 1 : n
    const now = new Date()
    const before = nMonthBefore(l) 

    return target && before < target && target < now
}
const 今月中 = allActiveScrap.filter(item => isThisMonth(new Date(item.closed_at)))

const 直近3ヶ月 = allActiveScrap.filter(item => isLastNMonth(new Date(item.closed_at), 3))

こんな感じか

hajimismhajimism
hajimismhajimism

Step

Clone this repository.
Change name and description in package.json => Auto synchronize with manifest
Run yarn install or npm i (check your node version >= 16.6, recommended >= 18)
Run yarn dev or npm run dev
Load Extension on Chrome
Open - Chrome browser
Access - chrome://extensions
Check - Developer mode
Find - Load unpacked extension
Select - dist folder in this project (after dev or build)
If you want to build in production, Just run yarn build or npm run build.

hajimismhajimism

usernameはlocalstrageのzenn_current_userに入っている。

const currentUserJson = localStorage.getItem('zenn_current_user')

const s = JSON.parse(currentUserJson)

const url = `https://zenn.dev/api/scraps?username=${s.cachedUser.username}`
hajimismhajimism

自分が欲しい物、拡張機能じゃない気がしてきた

hajimismhajimism

改めてユースケースを確認

「毎月末や毎四半期、毎年末などに、スクラップの総数やコメント数、内容を振り返ることでその期間を効果的に振り返る。」

欲しい機能

  • 毎月末や毎四半期、毎年末ごとにこの情報を算出してクリップボードにコピー
    • その期間中にCloseされたスクラップの総数、平均コメント数
    • その期間中にCloseされたスクラップのタイトルとリンクを[title](link)のかたちでリスト化したもの
hajimismhajimism

冷静になって整理する。

// 素材となるデータの取得方法
const data = await fetch('https://zenn.dev/api/scraps?username=ikenohi').then(res => res.json())
const allActiveScrap = data.scraps.filter(item => !item.archived)

// Scrapを入れれば集計してくれる
const countScraps = (scraps) => {
     const sum = (a,b) => a+b
     const totalScrapsCount = scraps.length
     const totalCommentsCount = scraps.map(item => item.comments_count).reduce(sum,0)
     const avarageCommentsCount = Math.round(totalCommentsCount / totalScrapsCount)

     const result = `総スクラップ数:${totalScrapsCount} \n総コメント数:${totalCommentsCount} \n平均コメント数:${avarageCommentsCount}`

     return result
}
hajimismhajimism
/* Scrapを期間指定でfilterするための関数群 */

// 月指定で月末23:59:59のDateを返してくれる
const endOfMonth = (month) => {
    const date = new Date()
    
    if (month) {
        date.setMonth(month + 1)
    } else {
        date.setMonth(date.getMonth() + 1)
    }
    
    date.setDate(0)
    date.setHours(23)
    date.setMinutes(59)
    date.setSeconds(59)
    
    return date
}

// 今月中ならtrueを返す
const isThisMonth = (target) => {
    return target && endOfMonth(-1) < target && target < endOfMonth()
}

// 今日からnヶ月前の日の0:00:00のDateを返してくれる
const nMonthBefore = (month) => {
    const date = new Date()
    date.setMonth(-month)
    date.setHours(0)
    date.setMinutes(0)
    date.setSeconds(0)

    return date
}

// 今日からnMonthBeforeの間ならtrueを返す
const isLastNMonth = (target, n) => {
    const l = n ===  undefined ? 1 : n
    const now = new Date()
    const before = nMonthBefore(l) 

    return target && before < target && target < now
}
hajimismhajimism

こんな感じで使える。

const 今月中 = allActiveScrap.filter(item => isThisMonth(new Date(item.closed_at)))
const 直近3ヶ月 = allActiveScrap.filter(item => isLastNMonth(new Date(item.closed_at), 3))

これでいいんか?2/1に1月の振り返りをしたくなったらどうする?

hajimismhajimism

ゴールから描こう

月の振り返り(1) // 今年1月の振り返り結果をクリップボードにコピー
四半期の振り返り(1) // 今年の四半期Q1(つまり1~3月)の振り返り結果をクリップボードにコピー
hajimismhajimism

いや、やっぱり直近3ヶ月みたいな振り返り方はしたい。3,4,5月の〜も欲しい

hajimismhajimism
この期間の振り返り(1) // 今年1月の振り返り結果をクリップボードにコピー
この期間の振り返り(1,4) // 今年1月~4月の振り返り結果をクリップボードにコピー

これだと年またいだときどうすんねん

hajimismhajimism

振り返り(a,b)a>=bなら去年のa月から今年のb月にするか。

hajimismhajimism

全体像?

const reflectionWithScrap = (n, m) => {
  const result = !m
    ? getReflectionForMonth(n)
    : getReflectionBetweenMonths(n, m);

  navigator.clipboard.writeText(result);
};
hajimismhajimism

明らかに抽象化できるけどとりあえず思いつきで書いてる


const getReflectionForMonth = (n) => {
  // 今年のn-1月末よりあと、今年のn月末以前のスクラップを対象に集計

  const filterFn = (target) => {
    return target && endOfMonth(n - 1) < target && target < endOfMonth(n);
  };

  const scrapsToBeCovered = scraps.filter((item) => filterFn(item.closed_at));

  const countSummary = countScraps(scrapsToBeCovered);
  const list = listScraps(scrapsToBeCovered);

  return `## サマリー \n${countSummary} \n## 対象スクラップ \n${list}`;
};

const getReflectionBetweenMonths = (n, m) => {
  if (n >= m) {
    // 昨年のn-1月末よりあと、今年のm月末以前のスクラップを対象に集計
  } else {
    // 今年のn-1月末よりあと、今年のm月末以前のスクラップを対象に集計
  }
  return;
};
hajimismhajimism

やべ、全然頭働かない

const generateSummary = (scraps) => {
  const countSummary = countScraps(scraps);
  const list = listScraps(scraps);

  return `## サマリー \n${countSummary} \n## 対象スクラップ \n${list}`;
};

// スクラップのリンク付き箇条書きリストを作成
const listScraps = (scraps) => {
  const bulletedList = scraps.map(
    (scrap) => `- [${scrap.title}](https://zenn.dev/${scrap.path})`
  );

  return bulletedList.join("\n");
};
hajimismhajimism
hajimismhajimism

あー、サマリーの出力自体は全部一緒だからfilterだけ場合分けすればよかったんか

hajimismhajimism

endOfYearで年を指定できればこうかけるはず

const reflectionWithScrap = (n, m) => {
  const filterFn = (target) => {
    const args = {
      monthly: {
        first: [n - 1],
        second: [n],
      },
      between: {
        first: [n - 1],
        second: [m],
      },
      acrossYear: {
        first: [n - 1, thisYear - 1],
        second: [m],
      },
    };

    const key = !m ? "monthly" : n >= m ? "acrossYear" : "between";

    return (
      target &&
      endOfMonth(...args[key].first) < target &&
      target < endOfMonth(...args[key].second)
    );
  };

  const scrapsToBeCovered = scraps.filter((item) => filterFn(item.closed_at));
  const summary = generateSummary(scrapsToBeCovered);

  navigator.clipboard.writeText(summary);
};
hajimismhajimism

見通しを良くする

const reflectionWithScrap = (n, m) => {
  const filterFn = defineFilterFn(n, m);
  const scrapsToBeCovered = scraps.filter((item) => filterFn(item.closed_at));
  const summary = generateSummary(scrapsToBeCovered);

  navigator.clipboard.writeText(summary);
};

const thisYear = new Date().getFullYear();

const defineFilterArgs = (n, m) => {
  const args = {
    monthly: {
      first: [n - 1],
      second: [n],
    },
    between: {
      first: [n - 1],
      second: [m],
    },
    acrossYear: {
      first: [n - 1, thisYear - 1],
      second: [m],
    },
  };

  const key = !m ? "monthly" : n >= m ? "acrossYear" : "between";

  return args[key];
};

const defineFilterFn = (n, m) => (target) => {
  const filterArgs = defineFilterArgs(n, m);

  return (
    target &&
    endOfMonth(...filterArgs.first) < target &&
    target < endOfMonth(...filterArgs.second)
  );
};
hajimismhajimism

長くなったけどたぶんこういうこと

/*
 * === データの取得の部 ===
 */
const data = await fetch("https://zenn.dev/api/scraps?username=ikenohi").then(
  (res) => res.json()
);

const allActiveScrap = data.scraps.filter((item) => !item.archived);

/*
 * === 処理の総合の部 ===
 */
export const reflectionWithScrap = (n, m) => {
  const filterFn = defineFilterFn(n, m);
  const scrapsToBeCovered = allActiveScrap.filter((item) =>
    filterFn(item.closed_at)
  );
  const summary = generateSummary(scrapsToBeCovered);

  navigator.clipboard.writeText(summary);
};

/*
 * === フィルターの部 ===
 */

const thisYear = new Date().getFullYear();

// n, mの値によってfilterの引数を変える
const defineFilterArgs = (n, m) => {
  const args = {
    monthly: {
      first: [n - 1],
      second: [n],
    },
    between: {
      first: [n - 1],
      second: [m],
    },
    acrossYear: {
      first: [n - 1, thisYear - 1],
      second: [m],
    },
  };

  const key = !m ? "monthly" : n >= m ? "acrossYear" : "between";

  return args[key];
};

// 切り出すためにカリー化しただけ
const defineFilterFn = (n, m) => (target) => {
  const filterArgs = defineFilterArgs(n, m);

  return (
    target &&
    endOfMonth(...filterArgs.first) < target &&
    target < endOfMonth(...filterArgs.second)
  );
};

// その月の末日23:59:59のDateを返してくれる
const endOfMonth = (month, year) => {
  const date = new Date();

  if (year) date.setYear(year);

  if (month) {
    date.setMonth(month);
  } else {
    date.setMonth(date.getMonth() + 1);
  }

  date.setDate(0);
  date.setHours(23);
  date.setMinutes(59);
  date.setSeconds(59);

  return date;
};

/*
 * === サマリー生成の部 ===
 */

// 本体
const generateSummary = (scraps) => {
  const countSummary = countScraps(scraps);
  const list = listScraps(scraps);

  return `## サマリー \n${countSummary} \n## 対象スクラップ \n${list}`;
};

// 集計
const countScraps = (scraps) => {
  const sum = (a, b) => a + b;
  const totalScrapsCount = scraps.length;
  const totalCommentsCount = scraps
    .map((item) => item.comments_count)
    .reduce(sum, 0);
  const avarageCommentsCount = Math.round(
    totalCommentsCount / totalScrapsCount
  );

  const result = `総スクラップ数:${totalScrapsCount} \n総コメント数:${totalCommentsCount} \n平均コメント数:${avarageCommentsCount}`;

  return result;
};

// リンク月箇条書きリストの作成
const listScraps = (scraps) => {
  const bulletedList = scraps.map(
    (scrap) => `- [${scrap.title}](https://zenn.dev/${scrap.path})`
  );

  return bulletedList.join("\n");
};

hajimismhajimism

ていうかふつうにmonth/year指定させれば変な分岐いらんかったんでは

hajimismhajimism
reflectionWithScrap({year: 2023, month:1},{year:2023, month:1}) // 2023年1月の振り返り
reflectionWithScrap({year: 2023, month:1},{year:2023, month:3}) // 2023年1~3月の振り返り
reflectionWithScrap({year: 2022, month:12},{year:2023, month:3}) // 2022年12~2023年3月の振り返り
hajimismhajimism

これの特例として今月の振り返りすればよかったんでは

const reflectionOfThisMonth = () => {
  const year = new Date().getFullYear()
  const month = new Date().getMonth() + 1

  return reflectionWithScrap({year, month},{year, month}) 
}

みたいな

hajimismhajimism

いやでもそっちのがいいわ

/*
 * === データの取得の部 ===
 */
const data = await fetch("https://zenn.dev/api/scraps?username=ikenohi").then(
  (res) => res.json()
);

const allActiveScrap = data.scraps.filter((item) => !item.archived);

/*
 * === 処理の総合の部 ===
 */
const reflectionWithScrap = (from, to) => {
  const filterFn = (target) => {
    return (
      target &&
      endOfMonth(from.month, from.year) < target &&
      target < endOfMonth(to.month, to.year)
    );
  };

  const scrapsToBeCovered = allActiveScrap.filter((item) =>
    filterFn(item.closed_at)
  );
  const summary = generateSummary(scrapsToBeCovered);

  //   navigator.clipboard.writeText(summary);
  return summary;
};

/*
 * === フィルターの部 ===
 */
// その月の末日23:59:59のDateを返してくれる
const endOfMonth = (month, year) => {
  const date = new Date();

  if (year) date.setYear(year);

  if (month) {
    date.setMonth(month);
  } else {
    date.setMonth(date.getMonth() + 1);
  }

  date.setDate(0);
  date.setHours(23);
  date.setMinutes(59);
  date.setSeconds(59);

  return date;
};

/*
 * === サマリー生成の部 ===
 */

// 本体
const generateSummary = (scraps) => {
  const countSummary = countScraps(scraps);
  const list = listScraps(scraps);

  return `## サマリー \n${countSummary} \n## 対象スクラップ \n${list}`;
};

// 集計
const countScraps = (scraps) => {
  const sum = (a, b) => a + b;
  const totalScrapsCount = scraps.length;
  const totalCommentsCount = scraps
    .map((item) => item.comments_count)
    .reduce(sum, 0);
  const avarageCommentsCount = Math.round(
    totalCommentsCount / totalScrapsCount
  );

  const result = `総スクラップ数:${totalScrapsCount} \n総コメント数:${totalCommentsCount} \n平均コメント数:${avarageCommentsCount}`;

  return result;
};

// リンク月箇条書きリストの作成
const listScraps = (scraps) => {
  const bulletedList = scraps.map(
    (scrap) => `- [${scrap.title}](https://zenn.dev/${scrap.path})`
  );

  return bulletedList.join("\n");
};

hajimismhajimism
hajimismhajimism

いろいろあったけどたぶんこうです

const reflectionOfThisMonth = () => {
  const thisYear = new Date().getFullYear();
  const thisMonth = new Date().getMonth() + 1;

  const from = {
    year: thisMonth === 1 ? thisYear - 1 : thisYear,
    month: thisMonth === 1 ? 12 : thisMonth - 1,
  };

  const to = {
    year: thisYear,
    month: thisMonth,
  };

  return reflectionWithScrap(from, to);
};

/*
 * === データの取得の部 ===
 */
const data = await fetch("https://zenn.dev/api/scraps?username=ikenohi").then(
  (res) => res.json()
);

const allActiveScrap = data.scraps.filter((item) => !item.archived);

/*
 * === 処理の総合の部 ===
 */
const reflectionWithScrap = (from, to) => {
  const filterFn = (target) => {
    return (
      target &&
      endOfMonth(from.month, from.year) < target &&
      target < endOfMonth(to.month, to.year)
    );
  };

  const scrapsToBeCovered = allActiveScrap.filter((item) =>
    filterFn(new Date(item.closed_at))
  );
  const summary = generateSummary(scrapsToBeCovered);

  //   navigator.clipboard.writeText(summary);
  return summary;
};

/*
 * === フィルターの部 ===
 */
// その月の末日23:59:59のDateを返してくれる
const endOfMonth = (month, year) => {
  const date = new Date();

  if (year) date.setYear(year);

  if (month) {
    date.setMonth(month);
  } else {
    date.setMonth(date.getMonth() + 1);
  }

  date.setDate(0);
  date.setHours(23);
  date.setMinutes(59);
  date.setSeconds(59);

  return date;
};

/*
 * === サマリー生成の部 ===
 */

// 本体
const generateSummary = (scraps) => {
  const countSummary = countScraps(scraps);
  const list = listScraps(scraps);

  return `## サマリー \n${countSummary} \n## 対象スクラップ \n${list}`;
};

// 集計
const countScraps = (scraps) => {
  const sum = (a, b) => a + b;
  const totalScrapsCount = scraps.length;
  const totalCommentsCount = scraps
    .map((item) => item.comments_count)
    .reduce(sum, 0);
  const avarageCommentsCount = Math.round(
    totalCommentsCount / totalScrapsCount
  );

  const result = `総スクラップ数:${totalScrapsCount} \n総コメント数:${totalCommentsCount} \n平均コメント数:${avarageCommentsCount}`;

  return result;
};

// リンク月箇条書きリストの作成
const listScraps = (scraps) => {
  const bulletedList = scraps.map(
    (scrap) => `- [${scrap.title}](https://zenn.dev/${scrap.path})`
  );

  return bulletedList.join("\n");
};

hajimismhajimism

開発者ツールのときは、これのあとに

copy(reflectionOfThisMonth())
hajimismhajimism

ほんとはTemperMonkeyなんかに登録してボタン一つでできるようになりたいけど今は疲れたのでスニペットで使うということにしとく

このスクラップは2023/01/21にクローズされました