Skip to content

Instantly share code, notes, and snippets.

@hirohitokato
Last active August 15, 2024 03:22
Show Gist options
  • Save hirohitokato/b6da7237b63901880d9cbcc4088b0a23 to your computer and use it in GitHub Desktop.
Save hirohitokato/b6da7237b63901880d9cbcc4088b0a23 to your computer and use it in GitHub Desktop.
DenoでZennをクロールして、記事の一覧をMarkdown形式に変換して出力するスクリプト
#!/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/deno_dom@v0.1.47/deno-dom-wasm.ts";
import { format } from "https://deno.land/std@0.91.0/datetime/mod.ts";
import * as path from "https://deno.land/std@0.207.0/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