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

Zennで書いた記事を定期的にTwitterに上げて宣伝したい
いや、宣伝したいわけではないが自動でツイートされたら面白いと思う
- https://zenn.dev/kawarimidoll にアクセスする
- Articlesの一覧を見る
- 日付が新しいものをツイート
とりあえずタイトルだけ取り込んでみる
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>'
]
うん、いけそう
とりあえずリンクとタイトルだけツイートしても良いけどリンクを使って該当ページの中身まで見に行けばスクレイピング的に細かい情報も取得できるな

流石に正規表現でHTML解析していくのはしんどいのでDOMParserを使おう
主にこちら
こちらも参考になる?
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で取得するのとあんまり変わらないかも
即時性とかタグの充実とかを重視するかどうか…

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
宣伝ツイートとしてはこんなところだろうか

関数を別ファイルに分割する
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用の関数を使ってツイートしてみる
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
IFTTTを経由したので自動的にリンクがift.ttの短縮リンクになっているけど別に問題はないか

自動でバッチ実行したいのでGithub Actionsを使う
この際Github Actions上で環境変数(今回はIFTTT_WEBHOOK_KEY
)を設定する必要がある
GitHubのリポジトリのSettingsからSecretsへ入り、New repository secret
値を入れてAdd Secret
これ以降入力された値を確認する方法はない
一覧に追加されればOK
Updateボタンから値の変更をすることはできる

GitHub Actionで実行するとなるとmainから呼び出すんじゃなく同じファイルに纏めたほうが良い?
それだとモジュールにならないか
とりあえずこんな感じでワークフローを作成
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.example
でLINE_ACCESS_TOKEN
も定義しちゃっているのでエラーが起きそうな気がするがどうだろうか

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

で、やっぱり.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に投げるところで止まってる?

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)
ほほう…これはちょっとめんどくさいエラーかもしれませんよ

envはこちらの記事を参考に作った
.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
全体としてはこうなる
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

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

ページごとの<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: []
}
これライブラリ化できそう

こんな感じになった
なかなか良さそう…
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 };
}
};

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

published