Closed15

DenoでZennの自動宣伝ツイートをする

kawarimidollkawarimidoll

Zennで書いた記事を定期的にTwitterに上げて宣伝したい
いや、宣伝したいわけではないが自動でツイートされたら面白いと思う

  1. https://zenn.dev/kawarimidoll にアクセスする
  2. Articlesの一覧を見る
  3. 日付が新しいものをツイート

とりあえずタイトルだけ取り込んでみる

import { ky } from "./deps.ts";
const html = await ky("https://zenn.dev/kawarimidoll").text();
console.log(html.match(/<h3[^>]+>[^<]+<\/h3>/g));

実行 なお Velociraptorを使っているので vr startで動く

❯ vr start
[
  '<h3 class="ArticleCard_title__1l-jw">DenoでKyを使って極楽要求(しなさい)</h3>',
  '<h3 class="ArticleCard_title__1l-jw">IFTTTでTwitter投稿APIをつくる</h3>',
  '<h3 class="ArticleCard_title__1l-jw">Denoを(Vimで)開発するときのテンプレートを作った</h3>',
  '<h3 class="ArticleCard_title__1l-jw">Denoでコンソールとログファイルにログを出力する</h3>',
  '<h3 class="ArticleCard_title__1l-jw">DenoでLINE Notifyを使う</h3>'
]

うん、いけそう
とりあえずリンクとタイトルだけツイートしても良いけどリンクを使って該当ページの中身まで見に行けばスクレイピング的に細かい情報も取得できるな

kawarimidollkawarimidoll

流石に正規表現でHTML解析していくのはしんどいのでDOMParserを使おう

主にこちら
https://scrapingant.com/blog/deno-web-scraping

こちらも参考になる?
https://zenn.dev/mmomm/articles/59478e8046dd196d84f3

import { DOMParser, ky } from "./deps.ts";
const dom = new DOMParser().parseFromString(
  await ky("https://zenn.dev/kawarimidoll").text(),
  "text/html",
);
if (!dom) {
  console.error("Parse error");
  Deno.exit(1);
}
console.log("parse succeed");
const articles = dom.getElementsByTagName("article");
articles.forEach((article) => {
  const title = article.getElementsByTagName("h3")[0];
  const emoji = article.getElementsByClassName("emoji")[0];
  const link = article.getElementsByTagName("a")[1];
  console.log(title?.innerText || "no title");
  console.log(emoji?.innerText || "no emoji");
  console.log(link?.getAttribute("href") || "no link");
});

実行したところemojiを取得できなかった

❯ vr start
Check file:///Users/kawarimidoll/ghq/github.com/kawarimidoll/deno-dev-playground/main.ts
parse succeed
DenoでKyを使って極楽要求(しなさい)
no emoji
/kawarimidoll/articles/13c3f75f6f22d6
IFTTTでTwitter投稿APIをつくる
no emoji
/kawarimidoll/articles/234096fed2ee3c
Denoを(Vimで)開発するときのテンプレートを作った
no emoji
/kawarimidoll/articles/1c48c097020cbc
Denoでコンソールとログファイルにログを出力する
no emoji
/kawarimidoll/articles/b1d9bc15aaa99c
DenoでLINE Notifyを使う
no emoji
/kawarimidoll/articles/2937f4da6d9fa8

emojiは後からjsで埋め込んでいるのかな、これならRSSで取得するのとあんまり変わらないかも
即時性とかタグの充実とかを重視するかどうか…

kawarimidollkawarimidoll

1つ目の記事について整形してみた

import { DOMParser, ky } from "./deps.ts";
const ZENN_ROOT = "https://zenn.dev";
const ZENN_USER = "kawarimidoll";
const dom = new DOMParser().parseFromString(
  await ky(ZENN_USER, { prefixUrl: ZENN_ROOT }).text(),
  "text/html",
);
if (!dom) {
  console.error("Parse error");
  Deno.exit(1);
}
console.log("parse succeed");
const article = dom.getElementsByTagName("article")[0];
if (!article) {
  console.error("Article not found");
  Deno.exit(1);
}
const title = article.getElementsByTagName("h3")[0].innerText;
const links = article.getElementsByTagName("a");
const link = ZENN_ROOT + links[1].getAttribute("href");
const tags = links.reduce((acc, current) => {
  const path = current.getAttribute("href");
  return (path?.startsWith("/topics")) ? [...acc, current.innerText] : acc;
}, [] as string[]);
const readTime = links[links.length - 1].getElementsByTagName("span")[0]
  .innerText.replace(/^(\d+).*$/, "$1");
console.log(title);
console.log(link);
console.log(tags);
console.log(readTime);

const promotion = `${title}』という #Zenn 記事を書きました
${tags.map((t) => t + "とか").join("")}についていろいろ書いています
${readTime}分くらいで読めるのでスキマ時間のお供にどうぞ
${link}`;
console.log(promotion);
❯ vr start
parse succeed
DenoでKyを使って極楽要求(しなさい)
https://zenn.dev/kawarimidoll/articles/13c3f75f6f22d6
[ "ky", "fetch", "Deno" ]
5

『DenoでKyを使って極楽要求(しなさい)』という #Zenn 記事を書きました
kyとかfetchとかDenoとかについていろいろ書いています
5分くらいで読めるのでスキマ時間のお供にどうぞ
https://zenn.dev/kawarimidoll/articles/13c3f75f6f22d6

宣伝ツイートとしてはこんなところだろうか

kawarimidollkawarimidoll

関数を別ファイルに分割する

promote_zenn_article.ts
import { DOMParser, ky } from "./deps.ts";
const ZENN_ROOT = "https://zenn.dev";

const genPromotion = async (username: string) => {
  const dom = new DOMParser().parseFromString(
    await ky(username, { prefixUrl: ZENN_ROOT }).text(),
    "text/html",
  );
  if (!dom) {
    console.error("Parse error");
    Deno.exit(1);
  }

  console.log("parse succeed");
  const article = dom.getElementsByTagName("article")[0];
  if (!article) {
    console.error("Article not found");
    Deno.exit(1);
  }

  const title = article.getElementsByTagName("h3")[0].innerText;
  const links = article.getElementsByTagName("a");
  const link = ZENN_ROOT + links[1].getAttribute("href");
  const tags = links.reduce((acc, current) => {
    const path = current.getAttribute("href");
    return (path?.startsWith("/topics")) ? [...acc, current.innerText] : acc;
  }, [] as string[]);
  const tagText = tags[0]
    ? `${tags.join("とか") + (tags[1] ? "とか" : "")}についていろいろ書いています`
    : "";
  const readTime = links[links.length - 1].getElementsByTagName("span")[0]
    .innerText.replace(/^(\d+).*$/, "$1");

  return `${title}』という #Zenn 記事を書きました
${tagText}
${readTime}分くらいで読めるのでスキマ時間のお供にどうぞ
${link}
(本ツイートはDeno🦕で自動生成しています)`;
};

export { genPromotion };

main.tsでこれを読み込み、以前作ったtweet用の関数を使ってツイートしてみる

main.ts
import { IFTTT_WEBHOOK_KEY } from "./env.ts";
import { Logger } from "./logger.ts";
import { sendTweet } from "./tweet_with_ifttt.ts";
import { genPromotion } from "./promote_zenn_article.ts";

const message = await genPromotion("kawarimidoll");
Logger.info({ message });
Logger.info(await sendTweet({ message, key: IFTTT_WEBHOOK_KEY }));

実行

❯ vr start
Check file:///Users/kawarimidoll/ghq/github.com/kawarimidoll/deno-dev-playground/main.ts
logfile: ./app.log
parse succeed
2021-06-23T09:34:45+09:00 INFO    {"message":"『DenoでKyを使って極楽要求(しなさい)』という #Zenn 記事を書きました\nkyとかfetchとかDenoとかについていろいろ書いています\n5分くらいで読めるのでスキマ時間のお供にどうぞ\nhttps://zenn.dev/kawarimidoll/articles/13c3f75f6f22d6\n(本ツイートはDeno🦕で自動生成しています)"}
2021-06-23T09:34:46+09:00 INFO    Congratulations! You've fired the send_tweet event

https://twitter.com/KawarimiDoll/status/1407497324608294912
良い感じ
IFTTTを経由したので自動的にリンクがift.ttの短縮リンクになっているけど別に問題はないか

kawarimidollkawarimidoll

自動でバッチ実行したいのでGithub Actionsを使う
この際Github Actions上で環境変数(今回はIFTTT_WEBHOOK_KEY)を設定する必要がある

GitHubのリポジトリのSettingsからSecretsへ入り、New repository secret

値を入れてAdd Secret
これ以降入力された値を確認する方法はない

一覧に追加されればOK
Updateボタンから値の変更をすることはできる

kawarimidollkawarimidoll

GitHub Actionで実行するとなるとmainから呼び出すんじゃなく同じファイルに纏めたほうが良い?
それだとモジュールにならないか

とりあえずこんな感じでワークフローを作成

.github/workflows/promote_zenn_article.yml
name: tweet_promote_zenn_article

on:
  # 04:10UTC -> 13:10JST
  schedule:
    - cron: "10 4 * * *"

env:
  IFTTT_WEBHOOK_KEY: ${{secrets.IFTTT_WEBHOOK_KEY}}

jobs:
  promote:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@master
      - name: Setup Deno
        uses: denoland/setup-deno@v1
        with:
          deno-version: v1.x
      - run: deno run --allow-net --allow-read --allow-read=.env,.env.example,.env.defaults run_promote_zenn_article.ts

GitHub Actionsはそんなに時間ぴったりには動いてくれないということで暫し待機
.env.exampleLINE_ACCESS_TOKENも定義しちゃっているのでエラーが起きそうな気がするがどうだろうか

kawarimidollkawarimidoll

設定時刻から30分くらい過ぎてようやく実行された、と思ったら--allow-readを2回書いていたことで失敗した、凡ミス…!
sheduleだと確認が遅くなるので完成するまではpushイベントでも動くようにしておこう

ああ--allow-envも忘れていた…

kawarimidollkawarimidoll

で、やっぱり.env.exampleの項目が足りずエラーが出ていた

error: Uncaught MissingEnvVarsError: The following variables were defined in the example file but are not present in the environment:
  LINE_ACCESS_TOKEN

Make sure to add them to your env file.

これを追加したら実行できた…と思ったら意外なところで停止

parse succeed
{
  message: "『DenoでKyを使って極楽要求(しなさい)』という #Zenn 記事を書きました\nkyとかfetchとかDenoとかについていろいろ書いています\n5分くらいで読めるのでスキマ時間のお供にどうぞ\nht..."
}
error: Uncaught (in promise) TypeError: Cannot read property 'text' of undefined
    return await error.response.text();
                                ^
    at sendTweet (file:///home/runner/work/deno-dev-playground/deno-dev-playground/tweet_with_ifttt.ts:10:33)
    at file:///home/runner/work/deno-dev-playground/deno-dev-playground/run_promote_zenn_article.ts:7:19
Error: Process completed with exit code 1.

IFTTTに投げるところで止まってる?

kawarimidollkawarimidoll

console.error(await error)を足してみたところ…

TypeError: Cannot read property 'credentials' of undefined
undefined
    at new Ky (cdn.skypack.dev/-/ky@v0.28.5-87vwjRBOFXRmAMMqBlcq/dist=es2020,mode=imports/optimized/ky.js:130:32)
    at Function.create (cdn.skypack.dev/-/ky@v0.28.5-87vwjRBOFXRmAMMqBlcq/dist=es2020,mode=imports/optimized/ky.js:183:17)
    at Function.ky2.<computed> [as post] (cdn.skypack.dev/-/ky@v0.28.5-87vwjRBOFXRmAMMqBlcq/dist=es2020,mode=imports/optimized/ky.js:338:42)
    at sendTweet (file:///home/runner/work/deno-dev-playground/deno-dev-playground/tweet_with_ifttt.ts:8:21)

ほほう…これはちょっとめんどくさいエラーかもしれませんよ

kawarimidollkawarimidoll

envはこちらの記事を参考に作った
https://stackoverflow.com/questions/60176044/how-do-i-use-an-env-file-with-github-actions

.envファイルを生成し、必要な環境変数を適用する

- name: Create env file
  run: |
    touch .env
    echo IFTTT_WEBHOOK_KEY=${{ secrets.IFTTT_WEBHOOK_KEY }} >> .env

ところがこれだと.env.exampleに入っている他の環境変数が.envに無いため、deno_envのsafe modeだとエラーが出てしまう

したがって.env.exampleをコピーして枠組みを作り、必要な環境変数だけ適用する

- name: Create env file
  run: |
    cp .env.example .env
    echo IFTTT_WEBHOOK_KEY=${{ secrets.IFTTT_WEBHOOK_KEY }} >> .env

全体としてはこうなる

.github/workflows/promote_zenn_article.yml
name: tweet_promote_zenn_article

on:
  # 03:20UTC -> 12:20JST
  schedule:
    - cron: "20 3 * * *"

jobs:
  promote:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@master
      - name: Setup Deno
        uses: denoland/setup-deno@v1
        with:
          deno-version: v1.x
      - name: Create env file
        run: |
          cp .env.example .env
          echo IFTTT_WEBHOOK_KEY=${{ secrets.IFTTT_WEBHOOK_KEY }} >> .env
      - run: deno run --allow-net --allow-read=.env,.env.example,.env.defaults --allow-env run_promote_zenn_article.ts
kawarimidollkawarimidoll

ちょっとランナーのあり方とかが気に食わないところはあるけど自動ツイートは出来てるし良しとしよう
それよか自分でAPI作れるかもしれん

kawarimidollkawarimidoll

ページごとの<script id="__NEXT_DATA__"></script>を取ってみる

import { DOMParser, ky } from "./deps.ts";
const ZENN_ROOT = "https://zenn.dev";
const showData = async (subPath = "") => {
  console.log(subPath);

  const dom = new DOMParser().parseFromString(
    await ky(subPath, { prefixUrl: ZENN_ROOT }).text(),
    "text/html",
  );

  if (!dom) {
    throw new Error("Dom parse failed");
  }

  const data = dom.getElementById("__NEXT_DATA__");

  if (!data) {
    throw new Error("There is no data field");
  }

  console.log(JSON.parse(data.innerText));
};

await showData();
await showData("articles");
await showData("articles/explore");
await showData("books");
await showData("books/explore");
await showData("scraps");
await showData("scraps/explore");
await showData("topics");
await showData("kawarimidoll");
{
  props: {
    pageProps: {
      dailyTechArticles: [
        [Object], [Object], [Object],
        [Object], [Object], [Object],
        [Object], [Object], [Object],
        [Object], [Object], [Object],
        [Object], [Object], [Object],
        [Object], [Object], [Object],
        [Object], [Object]
      ],
      dailyIdeaArticles: [ [Object], [Object], [Object], [Object], [Object], [Object] ],
      dailyBooks: [
        [Object], [Object],
        [Object], [Object],
        [Object], [Object],
        [Object], [Object],
        [Object], [Object]
      ]
    },
    __N_SSP: true
  },
  page: "/",
  query: {},
  buildId: "VEZIl64_cqCl2dus4P4tW",
  isFallback: false,
  gssp: true,
  scriptLoader: []
}
articles
{
  props: {
    pageProps: {
      articles: [
        [Object], [Object], [Object], [Object],
        [Object], [Object], [Object], [Object],
        [Object], [Object], [Object], [Object],
        [Object], [Object], [Object], [Object],
        [Object], [Object], [Object], [Object],
        [Object], [Object], [Object], [Object],
        [Object], [Object], [Object], [Object],
        [Object], [Object], [Object], [Object],
        [Object], [Object], [Object], [Object],
        [Object], [Object], [Object], [Object],
        [Object], [Object], [Object], [Object],
        [Object], [Object], [Object], [Object]
      ],
      currentPage: 1,
      nextPage: 2,
      errorCode: null
    }
  },
  page: "/articles",
  query: {},
  buildId: "VEZIl64_cqCl2dus4P4tW",
  isFallback: false,
  gip: true,
  scriptLoader: []
}
articles/explore
{
  props: {
    pageProps: {
      weeklyTechArticles: [
        [Object], [Object],
        [Object], [Object],
        [Object], [Object],
        [Object], [Object],
        [Object], [Object],
        [Object], [Object],
        [Object], [Object],
        [Object], [Object]
      ],
      alltimeTechArticles: [
        [Object], [Object], [Object],
        [Object], [Object], [Object],
        [Object], [Object], [Object],
        [Object], [Object], [Object],
        [Object], [Object], [Object],
        [Object], [Object], [Object],
        [Object], [Object]
      ],
      weeklyIdeaArticles: [
        [Object], [Object],
        [Object], [Object],
        [Object], [Object],
        [Object], [Object]
      ],
      alltimeIdeaArticles: [
        [Object], [Object], [Object],
        [Object], [Object], [Object],
        [Object], [Object], [Object],
        [Object], [Object], [Object],
        [Object], [Object], [Object],
        [Object], [Object], [Object]
      ],
      randomFeaturedArticles: [ [Object], [Object], [Object], [Object], [Object], [Object] ],
      newArticles: [ [Object], [Object], [Object], [Object], [Object], [Object] ],
      popularTopics: [
        [Object], [Object],
        [Object], [Object],
        [Object], [Object],
        [Object], [Object],
        [Object], [Object]
      ]
    },
    __N_SSP: true
  },
  page: "/articles/explore",
  query: {},
  buildId: "VEZIl64_cqCl2dus4P4tW",
  isFallback: false,
  gssp: true,
  scriptLoader: []
}
books
{
  props: {
    pageProps: {
      books: [
        [Object], [Object], [Object], [Object], [Object],
        [Object], [Object], [Object], [Object], [Object],
        [Object], [Object], [Object], [Object], [Object],
        [Object], [Object], [Object], [Object], [Object],
        [Object], [Object], [Object], [Object], [Object],
        [Object], [Object], [Object], [Object], [Object],
        [Object], [Object], [Object], [Object], [Object],
        [Object], [Object], [Object], [Object], [Object],
        [Object], [Object], [Object], [Object], [Object],
        [Object], [Object], [Object], [Object], [Object],
        [Object], [Object], [Object], [Object], [Object],
        [Object], [Object], [Object], [Object], [Object]
      ],
      nextPage: 2,
      currentPage: 1
    },
    __N_SSP: true
  },
  page: "/books",
  query: {},
  buildId: "VEZIl64_cqCl2dus4P4tW",
  isFallback: false,
  gssp: true,
  scriptLoader: []
}
books/explore
{
  props: {
    pageProps: {
      randomFeaturedBooks: [ [Object], [Object], [Object], [Object], [Object], [Object] ],
      freeBooks: [
        [Object], [Object],
        [Object], [Object],
        [Object], [Object],
        [Object], [Object]
      ],
      alltimeBooks: [
        [Object], [Object],
        [Object], [Object],
        [Object], [Object],
        [Object], [Object],
        [Object], [Object]
      ],
      dailyBooks: [
        [Object], [Object], [Object],
        [Object], [Object], [Object],
        [Object], [Object], [Object],
        [Object], [Object], [Object],
        [Object], [Object], [Object],
        [Object], [Object], [Object],
        [Object], [Object]
      ]
    },
    __N_SSP: true
  },
  page: "/books/explore",
  query: {},
  buildId: "VEZIl64_cqCl2dus4P4tW",
  isFallback: false,
  gssp: true,
  scriptLoader: []
}
scraps
{
  props: {
    pageProps: {
      scraps: [
        [Object], [Object], [Object], [Object],
        [Object], [Object], [Object], [Object],
        [Object], [Object], [Object], [Object],
        [Object], [Object], [Object], [Object],
        [Object], [Object], [Object], [Object],
        [Object], [Object], [Object], [Object],
        [Object], [Object], [Object], [Object],
        [Object], [Object], [Object], [Object],
        [Object], [Object], [Object], [Object],
        [Object], [Object], [Object], [Object],
        [Object], [Object], [Object], [Object],
        [Object], [Object], [Object], [Object]
      ],
      nextPage: 2,
      currentPage: 1
    }
  },
  page: "/scraps",
  query: {},
  buildId: "VEZIl64_cqCl2dus4P4tW",
  isFallback: false,
  gip: true,
  scriptLoader: []
}
scraps/explore
{
  props: {
    pageProps: {
      dailyScraps: [
        [Object], [Object], [Object],
        [Object], [Object], [Object],
        [Object], [Object], [Object],
        [Object], [Object], [Object],
        [Object], [Object], [Object],
        [Object], [Object], [Object],
        [Object], [Object], [Object],
        [Object], [Object], [Object],
        [Object], [Object], [Object],
        [Object], [Object], [Object],
        [Object], [Object]
      ],
      alltimeScraps: [
        [Object], [Object], [Object], [Object],
        [Object], [Object], [Object], [Object],
        [Object], [Object], [Object], [Object],
        [Object], [Object], [Object], [Object],
        [Object], [Object], [Object], [Object],
        [Object], [Object], [Object], [Object],
        [Object], [Object], [Object], [Object],
        [Object], [Object], [Object], [Object],
        [Object], [Object], [Object], [Object],
        [Object], [Object], [Object], [Object],
        [Object], [Object], [Object], [Object],
        [Object], [Object], [Object], [Object]
      ],
      recentScraps: [
        [Object], [Object],
        [Object], [Object],
        [Object], [Object],
        [Object], [Object],
        [Object], [Object],
        [Object], [Object],
        [Object], [Object]
      ]
    },
    __N_SSP: true
  },
  page: "/scraps/explore",
  query: {},
  buildId: "VEZIl64_cqCl2dus4P4tW",
  isFallback: false,
  gssp: true,
  scriptLoader: []
}
topics
{
  props: {
    pageProps: {
      topics: [
        [Object], [Object], [Object], [Object], [Object],
        [Object], [Object], [Object], [Object], [Object],
        [Object], [Object], [Object], [Object], [Object],
        [Object], [Object], [Object], [Object], [Object],
        [Object], [Object], [Object], [Object], [Object],
        [Object], [Object], [Object], [Object], [Object],
        [Object], [Object], [Object], [Object], [Object],
        [Object], [Object], [Object], [Object], [Object],
        [Object], [Object], [Object], [Object], [Object],
        [Object], [Object], [Object], [Object], [Object],
        [Object], [Object], [Object], [Object], [Object],
        [Object], [Object], [Object], [Object], [Object]
      ]
    },
    __N_SSP: true
  },
  page: "/topics",
  query: {},
  buildId: "VEZIl64_cqCl2dus4P4tW",
  isFallback: false,
  gssp: true,
  scriptLoader: []
}
kawarimidoll
{
  props: {
    pageProps: {
      user: {
        id: 39895,
        username: "kawarimidoll",
        name: "kawarimidoll",
        avatarSmallUrl: "https://storage.googleapis.com/zenn-user-upload/avatar/icon_2379ac8d86.jpeg",
        avatarUrl: "https://storage.googleapis.com/zenn-user-upload/avatar/2379ac8d86.jpeg",
        bio: "バトルオペレーション!セット!イン!!",
        autolinkedBio: "バトルオペレーション!セット!イン!!",
        githubUsername: "kawarimidoll",
        twitterUsername: "kawarimidoll",
        isSupportOpen: true,
        tokusyoContact: null,
        tokusyoName: null,
        websiteUrl: "https://kawarimidoll.com",
        websiteDomain: "kawarimidoll.com",
        totalLikedCount: 31,
        gaTrackingId: "G-VBZG2PKP6K",
        followerCount: 1,
        followingCount: 0,
        articlesCount: 6,
        booksCount: 0,
        scrapsCount: 9
      },
      articles: [ [Object], [Object], [Object], [Object], [Object], [Object] ],
      itemType: "articles"
    }
  },
  page: "/[username]",
  query: { username: "kawarimidoll" },
  buildId: "VEZIl64_cqCl2dus4P4tW",
  isFallback: false,
  gip: true,
  scriptLoader: []
}

これライブラリ化できそう

kawarimidollkawarimidoll

こんな感じになった
なかなか良さそう…

zenn_api.ts
import { DOMParser, ky, SearchParamsOption } from "./deps.ts";
const ZENN_ROOT = "https://zenn.dev";

const callAPI = async (path = "", searchParams: SearchParamsOption) => {
  const html = await ky(path, { prefixUrl: ZENN_ROOT, searchParams }).text();
  const dom = new DOMParser().parseFromString(
    html,
    "text/html",
  );

  if (!dom) {
    throw new Error("Dom parse failed");
  }

  const data = dom.getElementById("__NEXT_DATA__");

  if (!data) {
    throw new Error("There is no data field");
  }

  const { props, page, query } = JSON.parse(data.innerText);

  return { ...props.pageProps, page, query };
};

const zennApi = async (page = "", query = {}) => {
  try {
    return await callAPI(page, query);
  } catch (error) {
    return { error: error.toString(), page, query };
  }
};
kawarimidollkawarimidoll

ちょっと他リポジトリでAPI作ってからTwitterの方にも適用してみよう、一旦Pending

このスクラップは2021/07/02にクローズされました