ちょっとした自作ツールたち

## はじめに
この記事は、[watnow Advent Calendar 2024](https://qiita.com/advent-calendar/2024/watnow-calender) 10日目の記事です。

エンジニアの皆さんであれば業務で直接デプロイされるコードの他に、手元でちょっとした作業をこなしたいときにプログラムを組むこともあるかと思います。
自分の場合モバイルからwebやCMS、各種SaaS/クラウドと幅広く扱うので雑多な中からいくつかお見せしたいと思います

## Firebaseユーザー更新くん

最大数万ユーザーほどの案件で、バックエンドをほぼFirebaseで完結しているんですが度々全ユーザーに対して一括操作を求められる場合があります。そういうときに全件取得して条件をつけて処理するJSコードです。念のため処理結果をjsonで残す機能つき。

```js:index.js
var admin = require("firebase-admin");
const fs = require('fs/promises');

var serviceAccount = require("path/to/key.json");

admin.initializeApp({
    credential: admin.credential.cert(serviceAccount)
});

const firestore = admin.firestore();

// コレクション参照を取得
const collectionRef = firestore.collection('myCollection');

// コレクションの全ドキュメントを取得して更新
collectionRef.get().then((querySnapshot) => {
    const documents = querySnapshot.docs;
    let totalUser = documents.length;
    let updateCount = 0;
    const updatedDataList = [];

    const promises = [];
    for (const doc of documents) {
        const data = doc.data();

        //  プロパティを書き換える例
        const propertyToUpdate = data.propertytoUpdate;
        const updateData = { propertyToUpdate: propertyToUpdate };
        promises.push(doc.ref.update(updateData));
        updateCount++;
        updatedDataList.push({ id: doc.id, propertyToUpdate: propertyToUpdate, executedAt: new Date().toISOString() });
            console.log(`ドキュメントID: ${doc.id} のpropertyToUpdateを更新しました`);
        }
    }

    Promise.all(promises).then(() => {
        // 更新したドキュメントをファイルに書き出し
        const json = JSON.stringify(updatedDataList, null, '  ');
        // スラッシュを含む日付文字列をファイル名に使うため、スラッシュをハイフンに置換
        const jstDate = new Date().toLocaleString("ja-JP", { timeZone: "Asia/Tokyo" }).replace(/\//g, '-').replace(/ /g, '-').replace(/:/g, '-');
        fs.writeFile(`./updated_data_${jstDate}.json`, json);

        console.log(`全${totalUser}件中、${updateCount}件を更新しました`);
    }).catch((err) => {
        console.error('更新中にエラーが発生しました:', err);
    });
});
```

このコードで問題なく動くんですがバッチ処理として継続的につかうならもっとちゃんとしたほうが良いです。冪等な処理を担保したり接続数を減らしたりできたはず。

## GoogleドキュメントをCosense（旧Scrapbox）にコンバートくん

文字通りの機能ですが僕の運用の仕方にかなり特殊化しています。Googleドライブはディレクトリのエクスポート（ローカルにダウンロード）が可能であり、一方でCosenseはすべてのページをjsonでインポート/エクスポートできるので前者から後者に変換するためのものです。当然ながらGoogleドキュメント（ダウンロードするとdocxになる）の書式などほとんどの機能はCosenseの文法にないのでその変換は未対応。

特殊な部分としてGoogleドライブの階層構造を保存するために「目次ページ」を生成するという機能があります。

```py:main.py
import json
import os
from docx import Document
import datetime

root_path = 'path/to/target/ExportedGoogleDrive'

def extract_text_from_docx(docx_path):
    # docxファイルからテキストを抽出し、改行で区切った配列を返す関数
    doc = Document(docx_path)
    text_array = []
    for paragraph in doc.paragraphs:
        text_array.extend(paragraph.text.split('\n'))
    return text_array

def get_files(root_path):
    pages = []
    extracted_root_path = os.path.basename(root_path)
    print(f'extracted_root_path = {extracted_root_path}')
    
    
    # 目次ページの作成。サブディレクトリ以下には対応していない
    index_page_lines = []
    index_page_lines.append(extracted_root_path)
    index_page_lines.extend(["","[目次ページ]","[This page was automatically generated.]","It was [generated at " + datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S') + "].",""])
    
    for root, dirs, files in os.walk(root_path):
        for dir in dirs:
            dirPath = os.path.join(root, dir)
            print(f'in {dirPath}, ')
            index_page_lines.append(" " + dir)
            
            for dirPath, inner_dirs, inner_files in os.walk(dirPath):
                for inner_file in inner_files:
                    if inner_file.endswith(".docx"):
                        try:
                            inner_file_name = inner_file.replace('.docx', '')
                            file_path = os.path.join(root, inner_file)
                            print(f'filePath = {inner_file_name}')
                        
                            index_page_lines.append("  [" + inner_file_name + ' from ' + extracted_root_path + "]")
                            
                            text_array = [
                                inner_file_name + ' from ' + extracted_root_path,
                                    "from [" + extracted_root_path + "]",
                                    "[This page was automatically generated.] It was [generated at " + datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S') + "].",
                                    "",
                                ]
                            text_array.extend(extract_text_from_docx(os.path.join(dirPath, inner_file)) 
                                                )
                            
                            pages.append({
                                "title": inner_file_name + ' from ' + extracted_root_path,
                                "lines": text_array
                            })

                        except Exception as e:
                            print(f"Error processing docx file: {file_path}, Error: {e}")
                    else:
                        print(f"Ignoring non-docx file: {inner_file}")
                        

    pages.append({
        "title": extracted_root_path,
        "lines": index_page_lines,
    })
    return pages

def main():
    new_pages = get_files(root_path)
    json_data = {
    "pages": new_pages
}
    

    with open('new.json', 'w') as f:
        json.dump(json_data, f,ensure_ascii=False)


if __name__ == '__main__':
    main()
```

## ホームページ素材からコンテンツ一覧のjsonを生成くん

企業やイベントなどの案件で更新頻度の少なく短期的な静的Webサイト、いわゆるホームページやランディングページというものをよく請け負うのですが、委託者の専門性が低いとデザインのPDFと素材だけ渡されてなんとかしてくださいというのが多いです。

自分はコンポーネントを使いたい（特にCSSの分離）のとTSで書きたいのと速いのでAstro.jsで作ることが多いのですが渡されたアセットが掲載するコンテンツのリストとして使えるので、そこから一覧データを作りコンポーネントを再利用しています。
そういったファイル一覧からそのリストをjsonで生成するコードです。

負担がかからないよう再帰処理はせず1階層までしか探索しません。

```py:main.py
import os
import json
import re


def to_snake_case(name):
    # ファイル名を小文字のスネークケースに変換
    name = re.sub(r'(?<!^)(?=[A-Z])', '_', name).replace('-', '_').lower()
    name = re.sub(r'[^a-z0-9_]', '_', name)
    return name


def generate_file_metadata(base_dir):
    file_data = []

    # ディレクトリ内のファイルとサブディレクトリを列挙
    for entry in os.listdir(base_dir):
        entry_path = os.path.join(base_dir, entry)

        if os.path.isfile(entry_path):
            # ファイルの場合
            name, ext = os.path.splitext(entry)
            # 隠しファイルは無視
            if name.startswith("."):
                continue
            file_data.append({
                "name": to_snake_case(name),
                "src": entry
            })
        elif os.path.isdir(entry_path):
            # サブディレクトリの場合
            for sub_entry in os.listdir(entry_path):
                sub_entry_path = os.path.join(entry_path, sub_entry)
                if os.path.isfile(sub_entry_path):
                    name, ext = os.path.splitext(sub_entry)
                    file_data.append({
                        "name": to_snake_case(name),
                        "src": f"{entry}/{sub_entry}"
                    })

    return file_data


def save_json(data, output_file):
    with open(output_file, 'w', encoding='utf-8') as f:
        json.dump(data, f, ensure_ascii=False, indent=4)


if __name__ == "__main__":
    # ベースディレクトリを指定
    base_directory = "path/to/base/directory"
    output_file = "constants.json"

    # JSONデータを生成
    metadata = generate_file_metadata(base_directory)

    # JSONファイルとして保存
    save_json(metadata, output_file)

    print(f"JSONデータを{output_file}に保存しました。")
```

ちなみに大文字のファイル名を読み込むとアンダーバーが挿入され`FILENAME => f_i_l_e_n_a_m_e`のようになってしまうバグがあります。直したらコメントしてください。

愚痴ですが、上記の様な性質、特に短期的な案件は本当にコスパが悪いので気をつけましょう。契約時にどこまでやるか、いつまで文句を受け付けるかはっきりさせておくのが重要。下請け法もできたので強気に。

## fanboxから全件保存くん

**スクレイピングです。規約を遵守し、負荷のかからない方法で自己責任のもと使用してください。**

規約で禁止されていなければ使用していいと思います。そんなものを公開すべきではないという意見も理解できますが、サービス側もAPIや機能を公開していないという不条理さがあるのでいちユーザーの権利としてこれぐらいさせてくれとも思います。

少なくとも技術的なリスクを考えられるリテラシーのある人だけ使えるよう多少不十分に書いておきます。
またセレクタもすぐ使えなくなると思います。

```py:main.py
from selenium import webdriver
from selenium.webdriver.common.by import By
import pickle
import time
import requests
import os
from datetime import datetime

interval = 99
save_dir = 'path/to/save'
cookie_file = 'cookies.pkl'
max_page = 999

url = 'direct/link/to/posts'
login_url = 'url/to/login/page'
email = 'example@example.com'
password = 'xxxxxx'


def save_cookies(driver, cookie_file):
    cookies = driver.get_cookies()
    with open(cookie_file, 'wb') as file:
        pickle.dump(cookies, file)
    print("save cookies")


def load_cookies(driver, cookie_file):
    with open(cookie_file, 'rb') as file:
        cookies = pickle.load(file)
        for cookie in cookies:
            driver.add_cookie(cookie)
    print("load cookies")


def login_and_save_cookies(driver, cookie_file):
    driver.get(login_url)

    input_element = driver.find_element(
        By.XPATH, "//*[contains(@placeholder, 'メールアドレスまたは')]")
    input_element.send_keys(email)
    password_input = driver.find_element(
        By.XPATH, "//*[contains(@placeholder, 'パスワード')]")
    password_input.send_keys(password)

    login_buttons = driver.find_elements(
        By.XPATH, "//button[contains(text(), 'ログイン')]")
    for button in login_buttons:
        if 'ログイン' in button.text:
            button.click()
            break

    time.sleep(5)

    input("Confirm reCAPTCHA and Press Enter to continue")

    save_cookies(driver, cookie_file)


def use_session(driver, cookie_file):
    if os.path.exists(cookie_file):
        driver.get(url)
        load_cookies(driver, cookie_file)
        driver.refresh()
    else:
        login_and_save_cookies(driver, cookie_file)


def proceed_modal(driver, interval):
    cacm_elements = driver.find_elements(
        By.CSS_SELECTOR, "[class*='ConfirmAdultContentModal__ButtonWrapper']")
    for element in cacm_elements:
        buttons = element.find_elements(By.TAG_NAME, 'button')
        for button in buttons:
            if 'はい' in button.text:
                button.click()
                break
    time.sleep(interval)


def format_date(date_str):
    dt = datetime.strptime(date_str, "%Y年%m月%d日 %H:%M")
    formatted_date = dt.strftime("%Y-%m-%d-%H:%M")
    return formatted_date


if __name__ == '__main__':
    driver = webdriver.Chrome()
    try:
        use_session(driver, cookie_file)
        time.sleep(interval)

        page_buttons = driver.find_elements(
            By.CSS_SELECTOR, "[class*='Pagination__StyledLink-sc']")
        for page_index in range(max_page):
            if page_index < 4:
                continue
            else:
                page_buttons = driver.find_elements(
                    By.CSS_SELECTOR, "[class*='Pagination__StyledLink-sc']")
                for button in page_buttons:
                    if str(page_index) in button.text:
                        button.click()
                        time.sleep(interval)
                        break
                    else:
                        print(
                            f'page {page_index} not found.\n it might be the last page.')
                        driver.quit()
                        break

            posts = driver.find_elements(
                By.CSS_SELECTOR, "[class*='CardPostItem__Wrapper-sc']")
            for post_index in range(len(posts)):
                print(f'this is {post_index}th post of {
                      len(posts)} posts in {page_index}th page.')
                posts = driver.find_elements(
                    By.CSS_SELECTOR, "[class*='CardPostItem__Wrapper-sc']")
                posts[post_index].click()
                time.sleep(interval)

                image_anchors = driver.find_elements(
                    By.CSS_SELECTOR, "[class*='PostImage__Anchor-sc']")
                print(f'found {len(image_anchors)} images.')
                page_title = str(driver.title)
                date = str(driver.find_element(
                    By.CSS_SELECTOR, "[class*='PostHeadBottom-sc']").text)

                for image_index, img in enumerate(image_anchors):
                    img_url = img.get_attribute('href')
                    print(f'img_url: {img_url}')

                    if img_url:
                        try:
                            cookies_dict = {cookie['name']: cookie['value']
                                            for cookie in driver.get_cookies()}
                            img_data = requests.get(
                                img_url, cookies=cookies_dict).content

                            formatted_date = format_date(date.split("・")[0])
                            page_title = page_title.split("｜")[0]

                            if not os.path.exists(os.path.join(save_dir, f'{formatted_date}_{page_title}')):
                                os.makedirs(os.path.join(
                                    save_dir, f'{formatted_date}_{page_title}'))

                            file_name = os.path.join(save_dir, f'{formatted_date}_{
                                page_title}', f'{image_index}.jpg')

                            with open(file_name, 'wb') as file:
                                file.write(img_data)
                            print(f'{file_name} を保存しました。')

                            time.sleep(0.5)
                        except Exception as e:
                            print(f'画像のダウンロード中にエラーが発生しました: {e}')
                driver.back()
                time.sleep(interval)

    except Exception as e:
        print(f'エラーが発生しました: {e}')
    finally:
        input("Press Enter to continue...")
        driver.quit()
```

動作している画面を見ていると楽しいのでヘッドレスではないです。Cookieを使用できますし、使わない場合reCAPCHAに飛ばされて手作業になります。凍結やIPバンもあるので常識の範疇で。

ちなみにChrome拡張機能で同じことをできるものがあるんですがディレクトリ設定が雑なのと負荷が大きそうなので使わないほうが良いと思います。

## 所感
これぐらいならAIが書いてくれるので備忘録的なスニペットの需要は減っていきそうですね