Last active
August 15, 2024 03:22
-
-
Save hirohitokato/b6da7237b63901880d9cbcc4088b0a23 to your computer and use it in GitHub Desktop.
DenoでZennをクロールして、記事の一覧をMarkdown形式に変換して出力するスクリプト
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env -S deno run --allow-net --allow-write | |
/** | |
* Zennのトップページまたは指定したタグのページを読み込み、Markdown形式でまとめて出力するスクリプト | |
* - オプションなしで実行すると"https://zenn.dev/articles/explore?tech_order=weekly"にアクセスし、 | |
* "yyyyMMddhhmm-trend.md" のファイル名でMarkdownファイルを生成する。 | |
* - `--tags タグ名`(タグはrustやtypescriptなど)で実行すると"https://zenn.dev/topics/タグ名"にアクセスし、 | |
* "yyyyMMddhhmm-{タグ名}.md" のファイル名でMarkdownファイルを生成する。 | |
* - `--stdout`オプションを付けて実行すると、ファイル出力せずに標準出力へMarkdownテキストを出力する | |
* | |
* 使用方法: | |
* `deno run zenncrawler.ts [options]` | |
*/ | |
import { parseArgs } from "node:util"; | |
import { DOMParser } from "https://deno.land/x/[email protected]/deno-dom-wasm.ts"; | |
import { format } from "https://deno.land/[email protected]/datetime/mod.ts"; | |
import * as path from "https://deno.land/[email protected]/path/mod.ts"; | |
const scriptName = path | |
.fromFileUrl(import.meta.url) | |
.split(/\\|\//) | |
.pop(); | |
/** parseArgs()に渡すParseArgsOptionConfigがdescriptionを持たないため、引数として渡すとエラーになってしまう。 | |
* そこで型を合わせるために小細工をしている。 | |
*/ | |
interface ParseArgsOptionConfig_Copy { | |
type: "string" | "boolean"; | |
multiple?: boolean | undefined; | |
short?: string | undefined; | |
default?: string | boolean | string[] | boolean[] | undefined; | |
} | |
type OptionConfig = ParseArgsOptionConfig_Copy & { | |
description: string; | |
}; | |
/** | |
* 記事1つ1つの情報を管理するクラス | |
*/ | |
class Article { | |
/** ユーザー名(name相当) */ | |
public username: string; | |
/** 絵文字 */ | |
public emoji: string; | |
/** 記事タイトル */ | |
public title: string; | |
/** コメント数 */ | |
public commentsCount: number; | |
/** いいね数 */ | |
public likedCount: number; | |
/** 本文の文字数 */ | |
public bodyLettersCount: number; | |
/** 記事のタイプ。"tech"など */ | |
public articleType: string; | |
/** 投稿日時 */ | |
public publishedAt: Date; | |
/** 最終更新日時 */ | |
public bodyUpdatedAt: Date; | |
/** zenn.dev以下のパス文字列 */ | |
public path: string; | |
/** | |
* 記事のJSONオブジェクトからArticleインスタンスを生成するコンストラクタ | |
* @param article 記事のJSONオブジェクト | |
*/ | |
// deno-lint-ignore no-explicit-any | |
constructor(article: { [name: string]: any }) { | |
this.username = article["user"]["name"]; | |
this.title = article["title"]; | |
this.commentsCount = article["commentsCount"]; | |
this.likedCount = article["likedCount"]; | |
this.bodyLettersCount = article["bodyLettersCount"]; | |
this.articleType = article["articleType"]; | |
this.emoji = article["emoji"]; | |
this.publishedAt = new Date(article["publishedAt"]); | |
this.bodyUpdatedAt = new Date(article["bodyUpdatedAt"]); | |
this.path = article["path"]; | |
} | |
/** | |
* 情報をMarkdown形式の文字列に整形して返す。 | |
* @returns Markdown形式で整形した文字列 | |
*/ | |
public toMarkdownString() { | |
return `${this.lastUpdateDateString()} [${this.emoji} ${ | |
this.title | |
}](https://zenn.dev${this.path}) by ${this.username} / ${ | |
this.likedCount | |
} likes / ${this.bodyLettersCount}文字`; | |
} | |
private lastUpdateDateString(): string { | |
return format(this.bodyUpdatedAt, "yyyy/MM/dd HH:mm"); | |
} | |
} | |
// deno-lint-ignore no-explicit-any | |
function parseArticles(articles: { [name: string]: any }[]): string[] { | |
const results: string[] = []; | |
for (const article of articles) { | |
results.push("* " + new Article(article).toMarkdownString()); | |
} | |
return results; | |
} | |
// 引数のパース | |
// parseArgsのoptionsで受ける型ParseArgsOptionConfigではdescriptionがないので | |
// parseArgs()に渡す際にエラーになってしまう。そのためanyで意図的に無視している | |
const options: { [name: string]: OptionConfig } = { | |
help: { | |
type: "boolean", | |
short: "h", | |
default: false, | |
multiple: false, | |
description: "show this help.", | |
}, | |
stdout: { | |
type: "boolean", | |
default: false, | |
multiple: false, | |
description: "output markdown to stdout.", | |
}, | |
tag: { | |
type: "string", | |
short: "t", | |
default: "", | |
multiple: false, | |
description: "specify the tag. if not set, fetch current trending.", | |
}, | |
}; | |
const parsedArgs = parseArgs({ | |
args: Deno.args, | |
options: options, | |
}); | |
if (parsedArgs.values.help) { | |
console.error(`Usage:\n\tdeno run ${scriptName} [options]`); | |
console.error("Options:"); | |
for (const [key, value] of Object.entries(options)) { | |
const short = value.short != undefined ? `, -${value.short}` : ""; | |
console.error(`\t--${key} ${short}: ${value.description}`); | |
} | |
Deno.exit(0); | |
} | |
/// ここからメインの処理 | |
const now = new Date(); | |
let url = "https://zenn.dev"; | |
let keys: [string, string][]; | |
let filename: string; | |
if (parsedArgs.values.tag) { | |
url += "/topics/" + parsedArgs.values.tag; | |
keys = [["articles", `## ${parsedArgs.values.tag}に関する記事`]]; | |
filename = format(now, "yyyyMMddHHmm") + `-${parsedArgs.values.tag}.md`; | |
} else { | |
url += "/articles/explore?tech_order=weekly"; | |
keys = [ | |
["weeklyTechArticles", "## Tech記事(Weekly)"], | |
["alltimeTechArticles", "## Tech記事(All Time)"], | |
]; | |
filename = format(now, "yyyyMMddHHmm") + "-trend.md"; | |
} | |
// データのフェッチとパース | |
const response = await fetch(url); | |
const html = await response.text(); | |
const dom = new DOMParser().parseFromString(html, "text/html"); | |
const jsonString = dom.querySelector("#__NEXT_DATA__")?.textContent; | |
if (!jsonString) { | |
console.error(`Could not get json string from "${url}"`); | |
Deno.exit(1); | |
} | |
const json = JSON.parse(jsonString); | |
const pageProps = json["props"]["pageProps"]; | |
// 結果を格納していく | |
const resultMarkdown: string[] = []; | |
// タイトル | |
resultMarkdown.push(`# ${format(now, "yyyy/MM/dd HH:mm")}のZenn記事`); | |
// プログラミングなどの技術についての知見 | |
for (const key of keys) { | |
resultMarkdown.push("", key[1], ""); | |
resultMarkdown.push(...parseArticles(pageProps[key[0]])); | |
} | |
if (parsedArgs.values.stdout) { | |
// 標準出力へ出力 | |
console.log(resultMarkdown.join("\n")); | |
} else { | |
// Markdownファイルとして出力 | |
await Deno.writeTextFile("./" + filename, resultMarkdown.join("\n")); | |
} | |
console.log("Finished."); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment