pukiwikiの記事を流し込む のバックアップ(No.2)

更新


プログラミング/svelte

将来的に移行することを考えて?

このサイトは pukiwiki を使って作られていて、 どうやら全部で 500 ページくらいあるみたい(え?!

これをデータとして流し込んでみる。

データベースの更新

もともとは markdown で記事を書く予定だったところに pukiwiki の記事を混ぜるとわけわからなくなる。

そこで、

  • 個々のページが pukiwiki 文法なのか markdown 文法なのかを表すフラグを用意する
  • pukiwiki 文法の場合、表示の際に pukiwiki → markdown → html の変換を行う

sqlite では enum は使えないらしいので該当項目は Int で我慢する。

あと、添付ファイルに誰がアップロードしたかの情報がなかったので追加

authorId に @map をつけ忘れていたので付けた

prisma/schema.prisma

  model User {
    id       String       @id @unique
    sessions Session[]
    keys     AuthKey[]
  
    name     String       @unique
    email    String       @unique
    roles    Role[]
    articles Article[]
+   attachments Attachment[]
  }

  model Article {
    id Int @id @default(autoincrement())
    author User @relation(fields: [authorId], references: [id]) 
    authorId String @map("author_id")
    title String
    body String
    newRevisionId Int? @unique @map("new_revision_id")
    oldRevision Article? @relation("ArticleRevision", fields: [newRevisionId], references: [id])
    newRevision Article? @relation("ArticleRevision")
    createdAt DateTime @default(now()) @map("created_at")
    deletedAt DateTime? @map("deleted_at")
    attachments Attachment[]
+   syntax Int @default(0) // 0:MARKDOWN, 1:PUKIWIKI
 
    @@index([deletedAt, title, createdAt])
    @@index([newRevisionId, deletedAt, title])
    @@index([newRevisionId, deletedAt, createdAt])
  }

  model Attachment {
    id String @ id @default(uuid())
    article Article @relation(fields: [articleId], references: [id])
*   articleId Int @map("article_id")
+   author User @relation(fields: [authorId], references: [id]) 
+   authorId String @map("author_id")
    body Bytes
    createdAt DateTime @default(now()) @map("created_at")
    modifiedAt DateTime @default(now()) @map("modified_at")
  
    @@index([articleId])
  }
LANG: console
$ pnpm prisma migrate dev --name "add syntax column to Article"
$ pnpm prisma migrate dev --name "modify Attachment"

データの取り込み

記事本文

pukiwiki/wiki に E38398E383ABE38397.txt みたいな訳の分からない名前のファイルがずら~っとあって、 これらが記事の本文

ファイル名は記事タイトルを英文字も全部含めて encodeURIComponent 的な変換をして % を取り除いたもの

日付はファイルの最終編集日時から取れる

バックアップファイル

このほかに backup に E38182E38184E38195E381A4.gz みたいなファイルがたくさんあって、ここに編集履歴が入っている。

>>>>>>>>>> 1100902204

のように10個の ">" に続けてスペース、10桁の数値が続く行がセパレータになっていて、 間に昔の記事が挟まる。

https://www.yoheim.net/blog.php?q=20141002

によれば zlib を使うと .gz ファイルを node だけで解凍できる。

数値は unix timestamp なので、1000 を掛けると javascript で Date にできる
https://qiita.com/shirokurotaitsu/items/5efd855900ec6135bbab

添付ファイル

attach フォルダに 4D656E75426172_747769747465722E706E67 みたいのがたくさんある

_ の前までがページ名で後ろがファイル名

このほかに .log というファイルがあって、カンマ区切りの数値が入っているのだけれど・・・ そこまで必要な情報ではなさそう???

これだけの情報があれば取り込みは簡単にできそう。

取り込み用のプログラム

とにかく列挙して、データベースに突っ込む。

newRevision を正しく設定しなければならないのでバックアップの処理だけちょっとややこしい。

新しい方から突っ込んで、古い方の newRevision に新しいやつの id を入れる。

id の若さと記事の古さが合わなくなるけど、まあたぶん問題にはならない・・・と思いたい?!

prismaseedPukiwikiData.ts

LANG: ts
import { db } from '../src/lib/server/db';
import * as fs from 'fs';
import * as zlib from 'zlib';

const pukiwikiFolder = 'pukiwiki/';
const wikiFolder = pukiwikiFolder + 'wiki/';
const backupFolder = pukiwikiFolder + 'backup/';
const attachFolder = pukiwikiFolder + 'attach/';

const authorId = "57e5q9g7t6ihvjt";

declare type PukiwikiPage = {title: string, encoded: string};
declare type PukiwikiAttach = {page: string, name: string, encoded: string, revision: number};

function getPages() {
  const pages = [] as PukiwikiPage[];

  const dir = fs.opendirSync(wikiFolder);
  for(let item=dir.readSync(); item; item=dir.readSync()){
    if(!item.name.match(/\.txt$/)) continue;
    const encoded = item.name.replace(/\.txt$/, '');
    const title = decodeURIComponent(encoded.replace(/../g, m=>'%'+m));
    pages.push({title, encoded});
  }
  dir.closeSync();

  return pages;
}

function getBackups() {
  const backups = [] as PukiwikiPage[];

  const dir = fs.opendirSync(backupFolder);
  for(let item=dir.readSync(); item; item=dir.readSync()){
    if(!item.name.match(/\.gz$/)) continue;
    const encoded = item.name.replace(/\.gz$/, '');
    const title = decodeURIComponent(encoded.replace(/../g, m=>'%'+m));
    backups.push({title, encoded});
  }
  dir.closeSync();

  return backups;
}

function getAttaches() {
  const attaches = [] as PukiwikiAttach[];

  const dir = fs.opendirSync(attachFolder);
  for(let item=dir.readSync(); item; item=dir.readSync()){
    if(item.name.match(/\.log$|^\.|\.html$/)) continue;

    const m = item.name.match(/\.(\d+)$/);
    const revision = m ? Number(m[1]) : 0;
    const encoded = item.name.replace(/\.\d+$/, '');

    const [page, name] = encoded.split(/_/).map(s=>
      decodeURIComponent(s.replace(/../g, m=>'%'+m)));

      attaches.push({page, name, encoded, revision});
  }
  dir.closeSync();

  return attaches;
}

async function insertPage(page: PukiwikiPage) {
  console.log(`ページを読み込み : ${page.title}`);

  const filepath = wikiFolder + page.encoded + '.txt';
  const body = fs.readFileSync(filepath).toString('utf-8');
  const date = fs.statSync(filepath).mtime;
  const record = await db.article.create({
    data: {
      authorId,
      title: page.title,
      createdAt: date,
      body,
      syntax: 1,  // pukiwiki
    }
  });
  return record.id;
}

// バックアップページを登録
// 最新ページがあればそれも登録
// 最新ページがなければ削除フラグを付ける
async function insertAllBackups(pages: PukiwikiPage[], backups: PukiwikiPage[]) {
  for(const backup of backups) {
    const filepath = backupFolder + backup.encoded + '.gz';
    const zipped = fs.readFileSync(filepath);
    const text = zlib.gunzipSync(zipped).toString('utf-8');
    const page = pages.find(page=> page.title == backup.title);
    let newer: number | null = null;
    if(page) {
      newer = await insertPage(page);
    }
    for(const block of text.split(/^>>>>>>>>>> (?=\d{10}$)/m).reverse()) {
      if(!block) continue;  // 先頭ブロックは空になる
      const dateStr = block.substring(0, 10);
      const date = new Date(Number(dateStr) * 1000);
      console.log(`  バックアップ : ${date.toLocaleString()}`);
      const body = block.substring(11);
      const record = await db.article.create({
        data: {
          authorId: authorId,
          title: backup.title,
          createdAt: date,
          body,
          syntax: 1,  // pukiwiki
          newRevisionId: newer,
          deletedAt: newer ? null : date, // 最新ページがなければ削除フラグ
        }                                 // 正しい時刻は分からない?
      });
      newer = record.id;
    }
  }
}

// バックアップを持ったないページを登録
async function insertAllPagesWithoutBackup(pages: PukiwikiPage[], backups: PukiwikiPage[]) {
  for(const page of pages) {
    if(!backups.find(backup=> page.title == backup.title)) {
      await insertPage(page); 
    }
  }
}

async function InsertAllAtaches(attaches: PukiwikiAttach[]) {
  for(const attach of attaches) {
    if(attach.revision > 0) continue;
    const article = await db.article.findFirst({where:{
      title: attach.page,
      deletedAt: null,
      newRevisionId: null,
    }});
    if(article) {
      console.log(`${attach.page} に ${attach.name} を添付`)
    } else {
      console.log(`*** ページ "${attach.page}" が存在しないため  ${attach.name} を添付できません`)
      continue;
    }
    const filepath = attachFolder + attach.encoded;
    const body = fs.readFileSync(filepath);
    const date = fs.statSync(filepath).mtime;
    const revmax = attaches.reduce((revmax, a)=> a.encoded == attach.encoded && a.revision > revmax ? a.revision : revmax, 0);
    let createdAt = date;
    if(revmax>0){
      createdAt = fs.statSync(filepath+'.'+revmax).mtime;
    }
    await db.attachment.create({
      data: {
        body,
        modifiedAt: date,
        createdAt,
        articleId: article.id, 
        authorId
      }
    });
  }
}

// pukiwiki タイプのデータを一旦すべて消す?
/*
await db.attachment.deleteMany({
  where: {
    article: {
      syntax: 1 // pukiwiki
    }
  }
});

await db.article.deleteMany({
  where:{
    syntax: 1 // pukiwiki
  }
});
*/

const backups = getBackups();
console.log(`${backups.length} 件のバックアップを確認`);

const pages = getPages();
console.log(`${pages.length} 件のページを確認`);

await insertAllBackups(pages, backups);
await insertAllPagesWithoutBackup(pages, backups);

const attaches = getAttaches();
console.log(`${pages.length} 件の添付ファイルを確認`);

await InsertAllAtaches(attaches);

これを、

LANG: console
$ pnpm ts-node prisma/seedPukiwikiData.ts

として実行。

原稿で記事の数は 500 件くらいなのだけれど、

バックアップも全部取り込んだおかげで 5,200 件くらいの Article が作られた(汗

Attachment は 1,300 件くらい

うへぇ

とはいえ sqlite だとこの件数でも数分で終わった。

dev.db のサイズは 410,345,472 ・・・ん?

400 MB あるのか。。。

LANG: console
$ git add . && git commit -m "pukiwiki 記事の取り込み"
       new file:   prisma/migrations/20231202005706_add_syntax_column_to_article/migration.sql
       new file:   prisma/migrations/20231202031246_modify_attachment/migration.sql
       modified:   prisma/schema.prisma
       new file:   prisma/seedPukiwikiData.ts

pukiwiki から markdown への変換

pukiwiki → markdown → html の変換がうまく行くようになれば、
最終的には pukiwiki → markdown を自動変換してしまえるはず。

pukiwiki の文法は実はかなりややこしい → 整形ルール

しかも掟破りの複数行処理プラグインを入れてたりするのでさらにややこしい。


Counter: 335 (from 2010/06/03), today: 2, yesterday: 1