🐱

OASIS を自分仕様にカスタマイズして Note × Zenn クロス投稿を実現するまで

に公開

OASIS の動作検証

公式ドキュメント

最新情報はこちらでご確認ください
https://pypi.org/project/oasis-article/
https://github.com/Sunwood-ai-labs/OASIS

筆者の環境

  • OS: Windows 11
  • エディタ: VSCode
  • シェル: PowerShell
  • 目標: Note と Zenn に同じ Markdown 記事をクロス投稿すること

仮想環境を作成して OASIS をインストール

まずは作業環境を整えます。
Python の仮想環境を作成し、その中に OASIS をインストールしました。

python -m venv venv
.\venv\Scripts\activate
pip install oasis-article

環境を分けておくことで、他のプロジェクトに影響を与えずに検証できます。


.env を用意して必要な認証情報・パスを設定

OASIS は外部サービス(Note、Zenn など)に投稿するため、認証情報や環境依存の設定が必要です。
.env ファイルを用意します。

例:

NOTE_EMAIL=xxxxx@example.com
NOTE_PASSWORD=xxxxxxxx
NOTE_USER_ID=xxxxxx
FIREFOX_BINARY_PATH= "C:\\Program Files\\Mozilla Firefox\\firefox.exe"

⚠️ なぜ Firefox が必要か?
「API」とされていますが、実際には Note 投稿は公式 API ではなく Selenium によるブラウザ自動操作です。
そのため Firefox 本体と geckodriver を用意する必要があります。

Firefox のパスを Windows 形式で指定するとき、
\f が制御文字として解釈されるため\ に修正しました。(そもそも""でくくらなければいいかも)

  • Firefox 本体
    👉 公式サイト からインストール

  • geckodriver
    👉 GitHub Releases (mozilla/geckodriver) から環境に合ったバイナリをダウンロード

    • Windows 64bit の場合:geckodriver-vXX.X.X-win64.zip
    • 展開して PATH の通った場所(今回であればvenv内のscripts配下)に配置

記事ファイルの準備

OASIS に読み込ませる Markdown ファイルや埋め込み画像は、articles/ 配下の1つのフォルダにまとめる必要があります
(1記事 = 1フォルダ 構成)

例:

articles/
 └─ test/
     ├─ index.md
     └─ cover.jpg

md ファイルの記法(Front Matter)

Markdown ファイルの先頭には Front Matter を書いて、記事タイトルやタグを指定します。
公式に想定されているフォーマットはこれじゃないようなので、のちにソースを修正します。

---
title: "oasis-article のテスト投稿"
slug: oasis-article-test
tags:
  - "Python"
  - "Zenn"
  - "Note"
---
  • title: 記事タイトル
  • slug: Zenn 側のファイル名や URL に使われる
  • tags: 文字列リスト(または name/slug 辞書形式でもOKらしい(未確認))

CLI から --folder_path を指定して実行

CLI で実行します。

oasis --folder_path ".\articles\test" --note --zenn

…が、ここでエラーやカスタマイズ項目が発生します。以下すべて解決策を後述します。

  • LLMのAPIが要求され、空白だとエラーとなり処理が走らない
  • note投稿時の公開下書きフラグが適切に設定できない(常に下書きになる)
  • Note投稿時にみんなのフォトを選んだあとに画面が止まる
  • zenn記事がデフォルトの C:\Prj\Zenn\articles に出力される(できれば任意のフォルダに入れたい)

LLM API が空白だと処理が走らない問題への対応

事象・展望

  • .envLLM_MODEL が設定されていないと処理が強制終了。
  • 実際には記事スラグやタグ生成にしか使っていないため、Note/Zenn 投稿の検証だけしたい場合でも止まってしまう。
  • mdで明示的に指定し、LLMは不要であったため、LLM処理を回避する方式に変更

対応方針

  • Frontmatter の値を最優先

    • slugtags が Markdown の Frontmatter にあればそれを使用する。
  • LLM はオプション化

    • LLM が未設定でも Frontmatter があればそのまま動作するようにする。
    • Fallback の slug 自動生成は行わず、Frontmatter または CLI 引数が無ければ処理が止まる設計。

修正個所

対象ファイル: oasis.py

frontmatterの設定

# 修正前
# Markdownファイルを読み込み
markdown_content, title = self.read_markdown(file_handler)
#修正後
# Markdownファイルを読み込み、Front Matter も取得
markdown_content, title, frontmatter = self.read_markdown(file_handler)

slug の決定

# 修正前
# 記事のスラグ(URL用の識別子)を生成
    slug = self.generate_slug(title)

#修正後
# --- slug の決定 ---
if frontmatter and "slug" in frontmatter:
    slug = frontmatter["slug"]
    logger.info(f"Front Matter の slug を使用します: {slug}")
elif slug:
    logger.info(f"CLI 引数の slug を使用します: {slug}")
else:
    slug = self.generate_slug(title)  # LLM利用(必須)

tags の決定

# 修正前
# 記事のカテゴリとタグを提案
suggestions = self.suggest_categories_and_tags(markdown_content, category_map, tag_map)

# 修正後
# --- tags の決定 ---
if frontmatter and "tags" in frontmatter:
    tags = []
    for t in frontmatter["tags"]:
        if isinstance(t, dict):
            tags.append({
                "name": t.get("name", ""),
                "slug": t.get("slug", t.get("name", "").lower())
            })
        else:
            tags.append({"name": str(t), "slug": str(t).lower()})
    suggestions = {"categories": [], "tags": tags}
    logger.info(f"Front Matter の tags を使用します: {tags}")
else:
    suggestions = self.suggest_categories_and_tags(markdown_content, category_map, tag_map)

Markdown 読み込み処理の改善

従来は タイトルと本文のみ 読み込んでいたが、Frontmatter を取り扱うために修正。

# 修正前
def read_markdown(self, file_handler):
    markdown_content, title = file_handler.read_markdown()
    logger.info(f"Markdownファイルの読み込みが完了しました: タイトル '{title}'")
    return markdown_content, title

# 修正後
def read_markdown(self, file_handler):
    markdown_content, title, frontmatter = file_handler.read_markdown()
    logger.info(f"Markdownファイルの読み込みが完了しました: タイトル '{title}', frontmatter '{frontmatter}'")
    return markdown_content, title, frontmatter

これにより、記事ごとに Frontmatter を埋め込んで slugtags明示的に指定可能 になった。


対象ファイル: file_handler.py

Markdownの読み取り変更

OASIS の動作検証の過程で、Markdown の Frontmatter を正しく解釈するためfile_handler.py を修正しました。

修正前(従来の処理)

# 元の処理: 単純にタイトル抽出
lines = content.split('\n')
title = next((line.strip('# ') for line in lines if line.startswith('#')), "デフォルトタイトル")
content = '\n'.join(lines[lines.index(next(line for line in lines if line.startswith('#')))+1:])
return content.strip(), title
  • Markdown 本文の先頭にある # タイトル を拾うだけのシンプル実装。
  • Frontmatter が書かれていても無視される。

修正版(Frontmatter を優先)

# --- 修正版: Front Matter の処理を追加 ---
frontmatter = {}
body = content
title = "デフォルトタイトル"
content = content.lstrip("\ufeff")  # BOM対策

if content.startswith("---"):
    parts = content.split("---", 2)
    if len(parts) >= 3:
        try:
            frontmatter = yaml.safe_load(parts[1])
            body = parts[2]
            logger.info(f"frontmatter{frontmatter}")
        except Exception as e:
            logger.warning(f"Front Matter の読み取りに失敗しました: {e}")
    else:
        logger.warning("Front Matter の区切りが不正です")

# タイトル決定
if "title" in frontmatter:
    title = frontmatter["title"]
else:
    for line in body.splitlines():
        if line.startswith("#"):
            title = line.strip("# ").strip()
            break

return body, title, frontmatter

修正ポイント

  1. Frontmatter を最優先

    • --- で始まる場合は YAML として解釈し、titletags を読み取る。
    • 読み取りに失敗した場合は警告ログを出す。
  2. タイトル決定の優先順位

    • Frontmatter の title → 本文先頭の # 見出し"デフォルトタイトル"
  3. 本文と Frontmatter を分離

    • OASIS 側で bodyfrontmatter を個別に利用できるようにした。

この修正により、Frontmatter を記述した Markdown を正しく読み込み、title / slug / tags を OASIS 側に渡せるようになりました。
従来の # 見出し 方式も残しているため、古い記事でも問題なく処理できます。

修正内容のまとめ

  • Frontmatter > CLI 引数 > LLM の優先順位に変更。
  • LLM を指定していなくても Frontmatter に slug/tags を書けば動作可能に。
  • read_markdown を拡張して Frontmatter を読み込むように変更。

note投稿時の公開下書きフラグが適切に設定できない(常に下書きになる)

現象

  • Note 投稿が常に下書きになる
  • note_publish=True をクラス初期化時や create_article の引数で指定しても無効。

試した修正 1: OASIS.__init__ 側で True に設定

class OASIS:
    def __init__(
        self,
        base_url=None,
        auth_user=None,
        auth_pass=None,
        llm_model=None,
        max_retries=3,
        qiita_token=None,
        qiita_post_private=True,
        note_email=None,
        note_password=None,
        note_user_id=None,
        note_publish=True,  # ← True にしてみた
        firefox_binary_path=None,
        firefox_profile_path=None,
        firefox_headless=False,
        note_api_ver = "v2",
        zenn_api_ver = "v2",
        zenn_output_path = None,
        zenn_publish: bool = False
    ):
        self.note_publish = note_publish

👉 しかしこれでは常に下書き保存のまま。


試した修正 2: NoteAPIV2.create_article のデフォルトを True に変更

def create_article(
        self,
        title: str,
        input_tag_list: List[str],
        image_index: Optional[str] = "random",
        post_setting: bool = True,   # ← True にしてみた
        file_name: Optional[str] = None,
        headless: bool = True,
        text: Optional[str] = None,
    ) -> Dict:

👉 これも効果なし。


最終対応: 途中で無理やり True に上書き

公開処理の直前で強制的に post_setting=True を代入。

logger.info(f"post_setting:{post_setting}")
post_setting = True  # ← 無理やり True にする
logger.info(f"post_setting:{post_setting}")

if post_setting:
    res = self._publish_article(driver, wait, input_tag_list)
else:
    res = self._save_draft(driver, wait)

res.update(
    {"title": title, "file_path": file_name, "tag_list": input_tag_list}
)
driver.quit()
logger.success("記事の作成が完了しました。")
return res

👉 これでようやく 公開モードで投稿されるようになった


ポイント

  • note_publish=Trueクラスの引数や関数デフォルトで指定するだけでは反映されない
  • 呼び出しのどこかでフラグがうまく渡らず、最終的には 関数内で強制代入する必要があった
  • 本来は CLI 引数 → OASIS クラス → NoteAPIV2 へ正しく伝播するべきなので、この実装は暫定対応。

Note 投稿時に「みんなのフォト」で止まる問題

現象

  • Note 投稿時に Selenium が カバー画像の選択画面(みんなのフォト) で止まる。
  • CLI 実行後、本文は入力されるが投稿が進まずフリーズする。

原因

  • NoteAPIV2._set_thumbnail 内で みんなのフォト選択ボタンを待機 (wait.until) しており、
    画面によっては対象要素が表示されず、TimeoutException で処理が止まっていた。
button = wait.until(
    EC.presence_of_element_located(
        (By.XPATH, "/html/body/div[5]/div/div/div[1]/div/div[2]/button")
    )
)

対応

👉 サムネイル処理をスキップするように修正。

修正前

self._set_thumbnail(driver, wait, search_word, image_index)

修正後(スキップするように変更)

# self._set_thumbnail(driver, wait, search_word, image_index)
logger.info("カバー画像設定をスキップしました")

ポイント

  • Note の API は実際はブラウザ自動操作なので、UI 側の要素に依存しやすい。
  • カバー画像がなくても投稿自体はできるので、まずは 処理をスキップして安定化 させた。
  • 将来的には「記事フォルダ内の画像ファイルをサムネイルにする」などの拡張が望ましい。

OASIS の Zenn 出力フォルダ問題の対応と本来の使い方

躓いたポイント:--zenn-output-path が反映されず固定フォルダに出力される

当初、Zenn 記事の出力先が常に C:\Prj\Zenn\articles に固定されており、どこかに指定して出力ができるとよい。

修正内容

  • cli.py
    OASIS 初期化時に以下のように修正:

    oasis = OASIS(
        ...
        zenn_output_path=args.folder_path,
        zenn_publish=args.zenn_publish
    )
    

    --folder_path の記事フォルダを ZennAPIV2.create_article() の出力先に渡すよう変更。

修正後の挙動

oasis --folder_path ".\articles\my-post" --zenn
  • 実際の出力ログ:

    DEBUG    output_dir: .\articles\my-post
    INFO     出力ディレクトリを確認/作成しました: .\articles\my-post
    SUCCESS  記事ファイルを作成しました: .\articles\my-post\oasis-article-test.md
    

本来の Zenn の使い方(GitHub 連携)

ただし、これはあくまで動作検証用の対応です。
**Zenn の正式な投稿手順は「GitHub 連携」**によるもので、以下の点に注意が必要です。

  • GitHub 連携では、Zenn 専用のレポジトリ(例:zenn-content)を作成し、
    その中の articles/ ディレクトリに .md を置く必要がある。
  • GitHub リポジトリと Zenn アカウントを連携しておけば、
    articles/ 以下の .md を push すると Zenn 上で記事が反映される。
  • よって、最終的な運用では OASIS の zenn_output_path
    指定した GitHub 連携用のローカルレポジトリ配下 (zenn-content/articles/) に設定できるするのが正しい。

今回の学び

  • Front Matter の値はプロジェクトに合わせて正規化する
    → YAML の書き方は揺れやすいため、文字列・辞書どちらにも対応できるように整形処理を入れておくと、自由度が上がる。

  • 固定値を避けて柔軟に引数・設定に寄せる
    --zenn-output-path のように CLI から渡せるようにしておくと、環境(Windows, Mac, CI/CD)や用途に応じて簡単に切り替えられる。

  • データ設計を自分の運用に合うように揃える
    tags の扱いのように、最初から「自分が使いたい形」に正規化しておくと、その後のコードはシンプルに保てる。
    入力データ側で揺れを吸収しておくと、下流の処理にカスタマイズを加えやすい。

今回は突貫でしたが本当はもう少し中の設計を考慮して修正をかけるべきでしたが、力及ばずでした。

Discussion