ダウンロードしたデータの取り扱いに注意
Qiita Team 内の記事は、その特性上公開すべきでない情報なども含まれているかもしれません。ダウンロード後の取り扱いには十分注意してください。記事をダウンロードしたことで何らかのトラブルになっても私は一切責任を負えませんのでご了承ください。
何らかの理由で、現在の Team から自分の投稿を取得したくなったとします。
Qiita Team にはエクスポート機能があって、記事を JSON で取得することができるのですが、残念ながらこれは、オーナー権限がないとできず、たとえ「管理者」権限であってもメニューは現れません。
ということで、仕方ないので API 使って取得します。
TL; DR
完成品はこちら↓
以下、細切れに説明していきますので、全文通して読みたい場合も上記のリポジトリを覗いてもらえればと
言語選定
今回は、最近あまり TS 書いてなかったので TypeScript でやってみます。
とりあえず、プロジェクトを初期化して…
pnpm init
必要なパッケージを追加していきます。
あと、今回はほぼ使い捨てだと思うので、ビルドとかもしないんで ts-node
でさくっと実行しちゃおうとおもいます。
尚、私は ni を愛用しているので以降コマンドはこれで書いていきますのでご了承下さい。(もう、npm とか pnpm のときはどうするんだっけとか覚えてない…)
ni axios dotenv
ni -D @types/node ts-node typescript
一応 scripts も更新
{
"scripts": {
"tsc": "tsc",
"get": "ts-node src/main.ts"
}
}
で、TypeScript の初期化も済ませておきます
nr tsc --init
環境変数の設定
.env
作ります。
OUTDIR="export"
MDFILE="index.md"
QIITA_TEAM="my-qiita-team"
QIITA_TOKEN="************************"
で、それをスクリプトに注入します
dotenv.config()
const TEAM_ID = process.env.QIITA_TEAM
const TOKEN = process.env.QIITA_TOKEN
const BASE_URL = `https://${TEAM_ID}.qiita.com/api/v2/`
const OUTDIR = process.env.OUTDIR
const MDFILE = process.env.MDFILE
投稿の取得
const fetchAllItems = async (): Promise<any[]> => {
const maxPages = 50 // ページング処理の最大数(もし50ページ以上、つまり5000件以上記事がある場合は、数値を調整)
const perPage = 100
let page = 1
let list: any[] = []
while(page <= maxPages) {
const res = await axios.get(`${BASE_URL}/authenticated_user/items`, {
headers: { Authorization: `Bearer ${TOKEN}` },
params: {
per_page: perPage,
page
}
})
if (res.data.length === 0 ) break
list.push(...res.data)
console.log(`page: ${page} : ${res.data.length} item(s)`)
page++
}
return list
}
自分の投稿を取得するためのエンドポイントとしては、
api/v2/users/:user_id/items
と、
api/v2/authenticated_user/items
があるっぽいですが前者はユーザーIDを指定して任意のユーザーが指定できる(ただし個人用アクセストークンで他の人の取れるのかは試してないので不明)のに対して、後者は、ユーザーIDの指定が不要なかわりに認証した本人の投稿のみが取り出せるようです。
今回は、自分の投稿だけでOKなので、後者を使いした。
今回はアクセストークンをつかって認証するので、axios でリクエストするときに Authorization ヘッダーをつけてあげます。
記事内の画像のパスを取得
このあと画像データをダウンロードするために、記事内の画像 URL を抽出していきます。
const extractImageUrls = (md: string): string[] => {
let match
// Markdown 記法用
const mdRegex = /!\[.*?\]\((https:\/\/.*?)\)/g
const urls: string[] = []
while((match = mdRegex.exec(md)) !== null) {
urls.push(match[1])
}
// HTML の img 要素用
const htmlRegex = /<img\s+[^>]*src=["'](https:\/\/[^"'>\s]+)["']/gi;
while ((match = htmlRegex.exec(md)) !== null) {
urls.push(match[1]);
}
return urls
}
取得した Markdown 本文をチェックしつつ、画像を埋め込んでいる記述を探します。
基本的には Markdown での記述 (
) を追えば良さそうですが、画像をセンタリングしたりするのに HTML の img
要素を使ったり してる場合もあると思うので、両方追加しました。
あとから img
のこと思い出して付け足したので本当はもうちょっとスマートなやり方あるかも…
画像ダウンロード
Qiita のサーバーにあがってるファイルを取得する場合も、Authorization ヘッダーが必要です。
忘れると、エラー画面のHTMLが書かれて拡張子だけ画像みたいな壊れたファイルになってしまいます。
const downloadImage = async (url: string, filePath: string) => {
const res = await axios.get( url, {
headers: {
Authorization: `Bearer ${TOKEN}`
},
responseType: 'arraybuffer'
})
fs.writeFileSync(filePath, res.data)
console.log(`saveImage: ${filePath}`)
}
FrontMatter の作成
ちゃんと FrontMatter 書くならばそれ用のライブラリを使えば良さそうですが、今回は雑に手書きしちゃいました。
const buildYamlFrontMatter = (item: any): string => {
const tagsYML = item.tags.map((tag: any) => ` - ${tag.name}`).join('\n')
return [
'---',
`title: ${item.title}`,
`created: ${item.created_at}`,
`modified: ${item.updated_at}`,
'tags:',
tagsYML,
'---'
].join('\n')
}
というのも、はじめはタグくらいあればいいかなと思ってたためなんですが、よく考えたら他にもいろいろとセットしておかないとあとで困りそうだったのでどんどん付け加えていったため、やっぱりちゃんとライブラリ使うべきだった…かもしれないですね
Markdown として保存
実際に取得した記事データを Markdown として保存します。
ローカルに置いとく場合、ファイル名は、記事タイトルを使うことが多いですが、ファイル名に使えない文字を記事タイトルにしてることとかもあるので、今回タイトルは FrontMatter にいれて、ファイル名は記事 ID を使うことにしました。
あとは、body
に素の Markdown テキストが入ってるのでそれと、先程生成した FrontMatter を合成してからファイルに保存すればOK
const savePostToMarkdown = async (item: any, outDir: string) => {
// タイトルにはファイル名に使えない文字が入ってることがあるので、とりあえずIDベースで保存
const itemId = item.id
let mdBody = item.body
const imageUrls = extractImageUrls(mdBody)
const frontmatter = buildYamlFrontMatter(item)
// 画像の保存先の準備
const imageDir = path.join(outDir, itemId, 'images')
fs.mkdirSync(imageDir, {recursive: true})
for (const url of imageUrls) {
const filename = path.basename(url.split('?')[0]) // 画像URLからクエリを除去
const localPath = `images/${filename}`
const savePath = path.join(imageDir, filename)
try {
await downloadImage(url, savePath)
mdBody = mdBody.replace(url, localPath) // 本文内の画像URLをローカルパスに書き換え
} catch (error) {
console.warn(`画像ダウンロード失敗: ${url}`, error)
}
}
const mdPath = path.join(outDir, itemId, MDFILE ?? 'index.md')
// Frontmatter と合体
const fullMdData = `${frontmatter}\n${mdBody}`
fs.writeFileSync(mdPath, fullMdData, 'utf-8')
console.log(
'post saved',
`title: ${item.title}`,
`outPath: ${mdPath}`
)
}
main()
の作成
最後に、ここまでつくったメソッドを呼び出して一連の処理がつながるようにして完成です
const main = async ():Promise<void> => {
if (!TEAM_ID||!TOKEN) {
console.error('環境変数未設定エラー')
process.exit(1)
}
const items = await fetchAllItems()
const outDir = path.join(__dirname, OUTDIR ? `../${OUTDIR}` : '../export')
// 出力先フォルダの作成
fs.mkdirSync(outDir, { recursive: true})
// JSON も一応とっておく
const jsonPath = path.join(outDir, 'export.json')
fs.writeFileSync(jsonPath, JSON.stringify(items, null, 2), 'utf-8')
for (const item of items) {
await savePostToMarkdown(item, outDir)
}
}
main().catch(console.error)
今回は一応、API から取れる JSON も保存しておくようにしました。
多分ダウンロードする必要があるってことは今後、API をつかってデータを取れなくなる可能性が高いと思うので、一応、ね。