見出し画像

PCやスマホでインプットしたURLを共有メニューから収集してマークダウンでまとめる機構を作った

一昨日から作り始めたインプットしたURLを集積する機構が一通り形になった。



何を作ったか

インプットしたタイトルとURLを、以下のような Markdown リスト形式で GitHub リポジトリの指定ファイルに記録するための機構。

- [HTML Sanitizer API - Web APIs | MDN](https://developer.mozilla.org/en-US/docs/Web/API/HTML_Sanitizer_API): 2023/10/2
- [Puppeteer | Puppeteer](https://pptr.dev/): 2023/10/2
- [AIDB on X: "GPT-4、Bard、Claude2などの異なるLLMが円卓を囲んで「ああでもないこうでもない」と議論した末に出す回答の精度が高いという検証報告がありました。 さらにこの"異…](https://twitter.com/ai_database/status/1707639676063162509): 2023/10/2


iOS では共有機能から "Add Inputs" を選ぶだけ。他に入力は不要。

macOS ではメニューバーに表示されている。Command + Shift + S で保存できるようにもしたのが個人的なお気に入り。


作った動機

これまで、インプットしたURLを雑に記録しておくために様々なツールを渡り歩いてきた。ブラウザブックマークから始まり、Pocket や Raindrop.io、 Notion などと。ツイートは Twitter ブックマークを使うといった具合だ。

これでも特段大きな問題はなかったのだが、どこまで行っても何かにロックインされているような気持ちであったので、Notion の無料枠が上限を迎えたことをきっかけとして作り始めた。


こだわりとしては以下が挙げられる

  • 追加入力は不要。タイトルの入力などはしたくないがある程度良い感じにしてほしい

  • インプットしているアプリの共有機能から必ず利用できてほしい

  • 共有するまでの押下ボタン数をできるだけ少なくしたい (面倒なのは嫌)

  • X (Twitter) の場合、ツイート内容をタイトルに入れたい

  • コピペで note の日記に貼り付けたい

  • 何かのサービスにロックインされることなく、柔軟に移設・移管できる

  • プライベートにしたいときにはできる


どのように作ったか

共有機能

まず共有機能は、iOS や macOS の「ショートカット」から作った。

共有機能がリッチなアプリは、共有時に URL だけでなくコンテンツも添えてくれるため、入力値はそのまま使えずURLを抽出している


ソフトウェアエンジニアが見れば分かるが、 「Text」で GitHub リポジトリの Actions on repository_dispatch のURLを指定している。その後、POST リクエストする。


GitHub Actions

リクエストを受け付けて、後述するスクリプトへ中継し、その結果を基にファイルを変更して、最終的にコミットする責務。

name: Add input

on:
  repository_dispatch:
    types: [add-input]

jobs:
  add-input:
    runs-on: ubuntu-latest

    steps:
    - name: Checkout repository
      uses: actions/checkout@v4

    - uses: oven-sh/setup-bun@v1
      with:
        bun-version: latest

    - uses: actions/cache@v3
      with:
        path: node_modules
        key: ${{ runner.os }}-${{ hashFiles('**/bun.lockb') }}
        restore-keys: |
          ${{ runner.os }}-

    - name: Install dependencies
      run: bun install

    - name: Install Chrome
      run: bunx @puppeteer/browsers install chrome@stable --path ${{ github.workspace }}/node_modules/@puppeteer

    - name: Run script and Update file
      id: script
      env:
        URL: ${{ github.event.client_payload.url }}
      run: |
        printf "%s\n%s" \
          "$(bun scripts/add-input.ts "${{ github.event.client_payload.url }}")" \
          "$(cat ./Inputs.md)" \
          > temp
        mv -f temp ./Inputs.md

    - name: Commit and push changes
      run: |
        git config --global user.name 'GitHub Actions'
        git config --global user.email 'actions@github.com'
        git add ./Inputs.md
        git commit -m "Add Input by GitHub Actions"
        git push

https://github.com/yoshikouki/yoshikouki/blob/main/.github/workflows/add-input.yml


コンテンツ取得・整形スクリプト

大して長くもないので全文載せる。最低限のサニタイズなどは行っているが、まあ自己責任でちゃんと意識しようねという感じの仕上がりになっている。

import { load } from "cheerio";
import puppeteer from "puppeteer";
import sanitizeHtml from "sanitize-html";
import { z } from "zod";

const UrlSchema = z.string().url();

const getUrl = (): string => {
  const url: string | undefined = process.env.URL || process.argv[2];
  try {
    UrlSchema.parse(url);
    return url;
  } catch (error) {
    throw new Error(
      `URL Error: ${error instanceof Error ? error.message : error}`
    );
  }
};

const fetchTitleByPuppeteer = async (url: string): Promise<string> => {
  const browser = await puppeteer.launch({
    headless: "new",
  });
  const page = await browser.newPage();
  try {
    await page.setUserAgent(
      "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36"
    );
    await page.goto(url, { waitUntil: "networkidle0", timeout: 3000 });
    const title = await page.title();
    return title;
  } catch (error) {
    if (error instanceof Error) {
      throw new Error(`Failed to retrieve the content. ${error.message}`);
    } else {
      throw new Error(`Failed to retrieve the content. ${error}`);
    }
  } finally {
    await browser.close();
  }
};

const extractTitle = (html: string): string => {
  const $ = load(html);
  const title = $("head title").text();
  return title;
};

const fetchTitle = async (url: string): Promise<string> => {
  const response = await fetch(url);
  if (response.status !== 200) {
    throw new Error(
      `Failed to retrieve the content. HTTP status: ${response.status}`
    );
  }
  const html = await response.text();
  const title = extractTitle(html);
  return title;
};

const truncate = (text: string, length: number): string => {
  if (text.length <= length) {
    return text;
  } else {
    const truncatedText = text.substring(0, length - 1);
    return truncatedText + "…";
  }
};

const formatContent = (content: string): string => {
  const formattedContent = content
    // Remove newlines
    .replace(/\n/g, " ")
    // Remove extra spaces
    .replace(/\s{2,}/g, " ")
    // Escape backticks
    .replace(/`/g, "\\`")
    .trim();
  const truncatedContent = truncate(formattedContent, 100);
  return truncatedContent;
};

const sanitize = (input: string): string => {
  return sanitizeHtml(input, {
    allowedTags: [], // No HTML tags are allowed
    allowedAttributes: {}, // No HTML attributes are allowed
  });
};

const isTwitterUrl = (url: string): boolean => {
  return /(twitter|x)\.com/.test(url);
};

const getContent = async (url: string): Promise<string> => {
  let rawContent: string;
  if (isTwitterUrl(url)) {
    // For Twitter, return the tweet text
    rawContent = await fetchTitleByPuppeteer(url);
  } else {
    // For other sites, return the page title
    rawContent = await fetchTitle(url);
  }
  if (!rawContent) {
    throw new Error("Failed to retrieve the content. No title found");
  }

  const sanitizedContent = sanitize(rawContent);
  const formattedContent = formatContent(sanitizedContent);
  return formattedContent;
};

const getTodayString = (): string => {
  const currentDate = new Date();
  const formattedDate = `${currentDate.getFullYear()}/${currentDate.getMonth() + 1}/${currentDate.getDate()}`;
  return formattedDate;
};


const run = async () => {
  const url = getUrl();
  const content = await getContent(url);
  const today = getTodayString();
  const output = `- [${content}](${url}): ${today}`;
  console.log(output);
};

run();

https://github.com/yoshikouki/yoshikouki/blob/main/scripts/add-input.ts

今回は node.js ではなく bun 習作ということで使ってみた。色々踏み抜きポイントがあって面白かったがそれはまた機会があれば書く。


集積先ファイル

今回はリポジトリ直下にした。

https://github.com/yoshikouki/yoshikouki/blob/main/Inputs.md

本当は GitHub Wiki に集めたかったのだが、  Actions 上での扱いやすさを選択した。追加日時の降順になる。Markdown Table にする案もあったが、note はテーブル形式に対応していないのでとりあえず箇条書きで。


まとめ

今後も使い勝手良くするように改善を続けていくと思うが、とりあえず形になったので満足。テストはいずれ生成AIに書いてもらう。

いいなと思ったら応援しよう!