0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

最新AI開発技術「LLM ContextPacker」APIキー不要の環境に依存しない!CursorやClaude Codeだけが正解じゃない。

0
Posted at

この記事で伝えたいこと

  • 「最新AIエディタを使えないと時代遅れになる」という焦りは不要。
  • APIキー確保の稟議の壁やレガシー環境(VBA マクロや組み込み系の言語、マイナー言語)でも、最新AIの恩恵は完全に受けられる。
  • AI開発の本質は「お作法」の習得ではなく、「コンテキスト(事実)」を正しく束ねてAIの巨大な脳に叩き込むことである。

image.png

1. はじめに:AIエディタの「お作法」に疲れていないか?

2026年現在、エンジニア界隈は日進月歩で進化するAIツールの話題で持ちきりです。「これからはCursor一択だ」「いやWindsurfの時代だ」「ついにターミナルで自律的に動くClaude Codeが出た」——タイムラインには常に新しいツールの「最強の使い方」が溢れています。

しかし、次々と登場する新しい「お作法(特有のコマンドやプロンプトの癖)」を追いかけることに、少し疲れていませんか?

もっと言えば、「最新ツールを使いたいが、社内のセキュリティ稟議が通らない」「自分の現場はレガシーなVBAや古い.NET環境だから、モダンなツールの恩恵を受けられない」と焦りや諦めを感じていないでしょうか。

特定のツールの数ヶ月先にまだ生き残っているか分からない操作フローを追いかけることは、本当に効率的でしょうか。メンバー間でツール習得の時間にばらつきが生じ、もし利用するAIツールや課金体系が変われば、その「お作法」に依存したスキルはすべて無(ゼロ)になります。

我々エンジニアが本当に時間を割くべきなのは、ツールの使い方を覚えることではなく、複雑な業務ロジックを整理するモデリング力や、
何年経っても色褪せない堅牢なデータベース設計、そして「プロジェクトとしてお客様に何が求められているのか」を深く把握するための要件定義や打ち合わせです。

AI開発の本質は、特定のエディタに依存することではありません。「コンテキスト(事実)」を束ねて、AIという巨大な脳(LLM)に丸投げする。
今回は、流行に振り回されず、ASP.NET(Visual Studio)やExcel/Access VBAといったあらゆる環境でAI開発を実現する『LLM ContextPacker』の概念と、それを支えるアーキテクチャの真実について語ります。

2. 魔法の裏側:自律型エージェントが抱えるアーキテクチャの限界

Claude Codeなどに代表される自律型エージェントは、あたかも魔法のようにリポジトリ全体を理解し、勝手にコードを修正してくれるように見えます。しかし、技術者としてその裏側(アーキテクチャ)を事実と推測に分けて解像度高く理解しておく必要があります。

【事実】内部は泥臭いコマンド実行の連続

自律型エージェントは、最初から数万行のリポジトリをすべて記憶しているわけではありません。ユーザーが指示を出すと、裏側で ls(ファイル一覧取得)、cat(ファイル閲覧)、grep(文字列検索)といったシェルコマンドを発行し、必要なファイルを「つまみ食い」してLLMのプロンプトに動的に差し込んでいます。

【事実】システマチックな「記憶の切り捨て」

これらのツールは、数万〜20万トークンという「コンテキスト上限(AIの短期記憶の限界)」と常に戦っています。そのため、システムはAPIコストの節約とパンク防止のために、裏側で以下のような処理を強行しています。

  • Tool Result Budget: grepの検索結果などが長すぎる場合、中間や末尾の情報を強制的に切り詰める。
  • Context Collapse / Snip Compact: 対話が長引くと、過去の会話ブロックや、初期にユーザーが与えた指示を丸ごと破棄、あるいは極端に要約する。

【推測とリスク】Context Rot(コンテキストの腐敗)による全体破綻

この「切り捨て」が、巨大なエンタープライズシステムにおいて致命的な牙を剥くことがあります。

例えば、「データベース接続は必ず独自定義の共通ラッパークラスを経由する」という制約を最初に指示したとします。しかし、エージェントがコンパイルエラーを直すために数十回のgrepと修正を繰り返すうち、初期のアーキテクチャ制約が「古い記憶」としてコンテキストから押し出されて(腐敗して)しまいます。

結果として、目先のエラーを直すために標準の SqlConnection を直接インスタンス化するようなコードを書き始めます。「局所的な修正は完璧だが、システム全体の規約は破綻する」——これがブラックボックス化された自律型ツールの抱える構造的リスクです。

3. アーキテクチャ頂上決戦:MCP(構造抽出) vs ContextPacker(全量テキスト)

image.png

左が MCP+AST(構造抽出)、右が ContextPacker(全量注入)の流れです。同じ「ソースコード全体」から出発しても、
MCP側は解析→構造化DBへの圧縮を経てAIが都度問い合わせる形になり、ContextPacker側は解析を挟まず巨大コンテキストへそのまま投入してAIが直接読む形になる、という対比が視覚的に分かるようにしました。

では、数百万行の巨大システムをAIに正しく理解させるにはどうすればいいのか。現在、最新のエンタープライズ開発では2つのアプローチが対立しています。

アプローチA:MCPと構文解析による「構造抽出」

最近話題の Codebase-Memory など、MCP(Model Context Protocol)という標準規格を利用した最先端の仕組みです。

  • 仕組み: Tree-sitter などの構文解析ツールを使って、コードの中から「クラス定義」「関数」「呼び出し関係」という骨組み(AST)だけを抽出し、DBに保存。AIはMCPという"インターフェース"を経由して、「この関数を呼んでいる場所を検索して」と外部ツールに問い合わせます。
  • メリット: トークン消費が極めて少なく、TypeScriptやGo言語などで構築された巨大なモダンWebアプリの依存関係解決には非常に強力です。
  • デメリット(事実): 言語の壁と環境構築の呪縛です。構文解析ツールが公式対応していないVB6や、魔改造されたAccess VBA、特殊な社内フレームワークでは一切機能しません。また、チーム全員にローカルサーバーの立ち上げやツール連携を強要することになります。

アプローチB:LLM ContextPacker(全量コンテキスト注入)

私が提唱する、極めて原始的かつ暴力的なアプローチです。

  • 仕組み: 構文解析などという小賢しい真似はしません。ソースコードの中身とファイルパスを単純なデリミタ(区切り文字)で繋ぎ合わせ、Gemini 3.1 Proなどが持つ100万〜200万トークンという超巨大なコンテキストにすべて一括で叩き込みます。
  • メリット: 究極の環境非依存性。 テキスト化さえできれば言語もIDEも問いません。さらに、変数名のニュアンス、コメントアウトされた暗黙知、特有のビジネスロジックなど、AST抽出では削ぎ落とされてしまう「生の文脈」をAIに完全に維持させることができます。

【初心者向け解説】MCPとASTって何?

MCP: AIツールと外部のデータベース等を標準化されたルールで繋ぐ「USBケーブル」のような仕組み。

AST(抽象構文木): コードのテキストを「主語」「動詞」のようにプログラムの文法構造として分解したデータ。「古い言語(VBAなど)」には対応ツールがないのが弱点です。

4. どんな環境でも「最強のAI」を降臨させる具体的手法とチーム運用

LLM ContextPackerの概念を利用すれば、専用のAIエディタが入り込めない現場でも、圧倒的なAI開発環境を構築できます。

LLM ContextPackerの概念を利用すれば、専用のAIエディタが入り込めない現場でも、圧倒的なAI開発環境を構築できます。しかし、この手法の真骨頂は**「チーム開発における属人性の排除」**にあります。

1. 設定ファイル(セーブデータ)のGit管理による「コンテキストの隠蔽」
自作した「ファイル文字列結合ツール」は、最後にドラッグ&ドロップしたファイルのリストを file_concatenator_settings.json として自動保存します。
プロジェクトにおいて、システム全体のアーキテクチャや共通ライブラリを最も深く理解しているテックリード(私)が、機能ごと・バッチごとに「AIに読ませるべき必要最低限のソースコード群(例: SampleProjectCore.csproj, Program.cs, Worker.cs など)」をツールに読み込ませ、そのJSONファイルを機能別のフォルダに配置し、Gitリポジトリにコミットしておきます。

📁 プロジェクト/★ファイル文字列結合ツール/
 ├── 📁 共通/
 │    ├── 📄 file_concatenator_settings.json (共通ライブラリ用の設定)
 │    └── 📄 ファイル文字列結合ツール.py
 ├── 📁 発注処理/
 │    ├── 📄 file_concatenator_settings.json (発注処理機能に必要なソース群の設定)
 │    └── 📄 ファイル文字列結合ツール.py
 ├── 📁 受注入力/
 │    ├── 📄 file_concatenator_settings.json (受注入力機能用の設定)
 │    └── 📄 ファイル文字列結合ツール.py
 └── ...

2. メンバーは「実行して投げる」だけ
プロジェクトメンバーは、共通ライブラリの仕様やシステム全体の依存関係を深く理解している必要はありません。
担当する機能(例えば「発注処理」)のフォルダにある結合ツールを起動すれば、Gitから降ってきたJSON設定が読み込まれ、自動的に「その機能を改修するのに最適なコンテキスト」がセットされた状態になります。あとは「ファイルを結合」ボタンを押し、出力されたテキストをGeminiに投げて依頼するだけです。

3. セキュリティとレガシー対応の両立

  • .NET環境: 上記の通り、設定済みのツールを叩くだけ。
  • VBA環境: 専用のマクロで全モジュールをテキスト出力し、結合ツールを経由してAIへ。
  • セキュリティ: 大企業ですでに契約済みのGoogle Workspace Enterprise環境(データが学習利用されないGemini)のチャット欄だけで完結し、外部SaaSエディタを経由しません。

これにより、メンバー個人の技術力やプロジェクト理解度に依存することなく、誰もが「システム全体を完璧に理解したAI」を安全な環境で使いこなせるようになります。

「設定済みの結合ツールを叩き、出力されたテキストをGeminiに投げる」。このシンプルな手順だけで、若手からベテランまで、属人化せずに「システム全体を理解したAI」を共同開発者として利用できます。

5. おわりに:カレーの「野菜パック」で満足するエンジニアでいいのか?

すでに準備された環境、外部SaaSに依存する最新のAIエディタを使った開発手法は、スーパーで売られている「カレー用のカット野菜パック」のようなものです。

確かに手軽で便利ですが、「もう少しじゃがいもを増やしたい」といった調整はできず、鮮度や価格は業者の言いなりです。

AIエディタという「野菜パック」の使い方にどれだけ習熟しても、それは「このメーカーの野菜パックの開け方」に詳しくなっただけで、あなた自身の料理の腕が上がったわけではありません。

もしあなたが、自分でスーパーに行き、新鮮な野菜(ソースコード)を選び、包丁で切り(コンテキストの結合・抽出)、状況に合わせて自由に調理(LLMへのプロンプト注入)できるスキルを持っているなら、その方が圧倒的に美味しく、安く、そして柔軟なシステム開発ができると思いませんか?

「LLM ContextPacker」という考え方は、ブラックボックス化された便利なツールへの依存から脱却し、エンジニア自身が「事実」と「コンテキスト」を抽出し、対話の土台を自らコントロールするためのアプローチです。

環境や稟議の壁に振り回されることなく、自分の現場に合った泥臭い技術的選択肢を持てるかどうか。それが、人生のCFOとして生き残るための、真の生存戦略なのだと信じています。

(※以下は、具体的なツールの実装と活用方法の詳細です。興味のある方は展開してご覧ください)

実践編:あらゆる開発環境をAI化する具体的な活用方法とツール

ここからは、実際に「LLM ContextPacker」の考え方を実現するための具体的な手法と、私が自作したファイル結合ツールのソースコードを公開します。

AIが一度に記憶できる「作業メモリ(コンテキストウィンドウ)」には上限があります。そのため、結合ツールには「約8000行ごとにファイルを分割して出力する」機能を持たせています。

6. 番外編:そこまでして自律型AIのご機嫌を取るべきか?(友人の実話より)

ここまで「人間MCP(ContextPacker)」の有用性と実践手法について解説してきました。
しかし、ここで一つの疑問が浮かびます。「コンテキストの腐敗や制限と戦ってでも、流行りの自律型エージェント(Claude Code等)を使うメリットはあるのか?」という点です。

先日、Claude Codeを愛用している友人からこんな話を聞きました。
「Claude Codeには5時間単位の使用量制限があるから、リセット時間を自分の作業コアタイムに合わせるために、毎朝6時と11時にスケジュール機能でAIに『おはよう』と自動送信して枠をコントロールしているよ」

……魔法のような最新ツールを使いこなしているつもりが、いつの間にか「AIの利用枠のご機嫌取り(スケジュールハック)」に人間のリソースや思考を割いてしまっている。少し面白い話ですが、これは流行りのツールに振り回される現代の開発現場のリアルな歪みでもあります。

では、どの環境・どの立場のエンジニアなら、この歪みやリスクを受け入れてでも自律型AIを使うべきなのか?
あるいは、私たちが提唱する「人間MCP(ContextPacker)」を採用すべきなのか?

image.png

結論から言えば、見ている世界(立場・環境・制約)によって「技術の正解」は180度変わります。4つの異なるエンジニアの視点から、両者のアプローチを5段階評価で本音レビューしてみました。

【立場別】自律型(MCP) vs 人間MCP 評価マトリクス

評価者の立場 自律型 (MCP・抽出型) ContextPacker (全量注入型) 評価の決定打(選定基準)
1. ベテラン (業務系・エンタープライズ) ★★☆☆☆ (2) ★★★★★ (5) アーキテクチャの維持と属人化の排除
2. インフラ・セキュリティ担当 ★☆☆☆☆ (1) ★★★★☆ (4) 権限統制と機密情報の流出リスク
3. 製造・組み込み系エンジニア ★★☆☆☆ (2) ★★★★★ (5) 独自環境・マイナー言語への適応力
4. フリーランス (個人開発) ★★★★★ (5) ★★☆☆☆ (2) 圧倒的な「初速」と「手軽さ」

1. ベテランエンジニア(業務系・エンタープライズ)の視点

「局所最適化による技術的負債が怖い。確実なコントロールが欲しい」

  • 自律型 (評価: 2)
    若手がこれを使って表面的に動くコードを量産するのは恐怖でしかありません。AIが裏でどんな grep を発行し、どの共通規約を「古い記憶」として切り捨てたのか、プルリクのレビュー時に人間が追えなくなるからです。「全体最適」ではなく「局所的なエラー潰し」に走る自律型ツールは、10年保守する巨大システムには劇薬すぎます。
  • ContextPacker (評価: 5)
    「設定ファイルのGit管理によるコンテキストの隠蔽」、この運用思想が素晴らしい。プロジェクトのコアを理解している人間(テックリード)が「AIに読ませるべき正解の束」を定義し、若手はそれを実行するだけ。これならアーキテクチャの品質を担保したまま、チーム全体の生産性を底上げできます。手動であることが、逆に最大の品質保証になっています。

2. インフラ・セキュリティ担当の視点

「自律プログラムに本番環境の構成管理を触らせるなど正気の沙汰ではない」

  • 自律型 (評価: 1)
    Terraformの定義ファイルや複雑なGCPプロジェクト群、IAM権限が書かれたリポジトリを、外部SaaSと連携した自律エージェントに読み書きさせる?絶対に稟議は通しません。AIが勝手に意図しないリソース変更のコードを書いたり、APIキーの推測に繋がる情報を外部へ送信するリスクをどう統制するのでしょうか。ブラックボックスな挙動は監査の観点でもNGです。
  • ContextPacker (評価: 4)
    ソースコードや環境定義表を「ただのテキストとして抽出」し、社内契約済みのセキュアなEnterprise版LLM(データが学習に利用されないGemini等)のチャット欄にペーストする。この原始的な一方向のデータフローなら、情報漏洩リスクもコントロールでき、既存のセキュリティポリシーにも適合します。現実的な導入ルートです。

3. 製造業・組み込み系エンジニアの視点

「モダンなAST解析ツールは、俺たちの現場では息をしていない」

  • 自律型 (評価: 2)
    「Tree-sitterで構造抽出します」と言われても、私たちが扱っているのはベンダー独自の魔改造C言語や、マイナーなRTOSのAPI、さらにはアセンブラです。最新の構文解析ツールは我々の環境をエラー判定してまともに読み込んでくれません。ハードウェアの制約を知らないAIに勝手にコードを直されても、予期せぬ不具合を起こすだけです。
  • ContextPacker (評価: 5)
    テキスト化さえできれば何でも入る、という暴力性が最高です。数千ページのマイコンのデータシート(PDFをテキスト化したもの)と、レガシーなソースコードをデリミタで繋いで、数百万トークンのGemini 3.1 Proに丸投げできる。言語の壁も構文エラーも関係ない。泥臭い現場の救世主になり得ます。

4. フリーランス(個人開発・スタートアップ)の視点

「面倒な準備は一切したくない。最速で動くMVPを作りたい」

  • 自律型 (評価: 5)
    CursorやClaude Codeは「神」です。お金を払ってでも無制限に使いたい。一人で全スタックを触るので、コンテキストの腐敗なんて気になりません。腐ったらリファクタリングさせるか、書き直せばいいだけ。朝思いついたサービスが夜にはデプロイできている。この「初速」はお金(売上)に直結します。
  • ContextPacker (評価: 2)
    巨大システムのチーム開発なら正解だと思いますが、個人のモダンWeb開発(Next.js + Vercelなど)では、わざわざ結合ツールを経由してテキストをコピペするのは時間がもったいないです。個人開発においては、筆者の言う「カレーのカット野菜パック」で十分お腹いっぱいになりますし、美味しいです。

「人間MCP」に対する反対派エンジニアたちのリアルな声

前回の評価マトリクスでは「ContextPacker」の堅牢性や確実性が高く評価されましたが、当然ながらこのアプローチにも弱点やトレードオフが存在します。
モダンな開発手法やAIのアーキテクチャに精通したエンジニアたちから寄せられるであろう、3つの鋭い反論・ツッコミを紹介します。

1. アジャイル開発のテックリードの視点

「設定ファイルのメンテ地獄。人間が『ボトルネック』になっていないか?」

  • 反論の要点: 運用コストと属人化の別形態への移行
  • 詳細:
    「『機能ごとにAIに読ませる正解のファイル群をJSONで定義し、Gitで管理する』というアイデアは一見綺麗ですが、そのJSONファイルを誰がメンテするんですか? という話です。
    システムは日々成長し、ファイルの分割やリファクタリング、共通クラスの追加が頻繁に起こります。そのたびにテックリードが『あ、あの機能のContextPacker用JSONも書き換えておかないと若手が古いソースをAIに読ませちゃう』と気を回すのは、現実的ではありません。
    結局、『AIのコンテキストの腐敗』を防ぐ代わりに、『人間が管理する設定ファイルの腐敗』を引き起こしているだけではないでしょうか。自律型エージェント(MCP)なら、少なくとも常に"最新のディレクトリ構造"から探索を始めてくれます」

2. DevOps / プラットフォームエンジニアの視点

「手動コピペはただの『トイル(無駄な作業)』。セキュリティは環境側で担保すべき」

  • 反論の要点: 自動化の放棄とアーキテクチャによる解決の欠如
  • 詳細:
    「インフラ担当が自律型AIを怖がる気持ちは分かりますが、だからといって『ツールを実行して、テキストをコピーして、ブラウザのGeminiにペーストする』という原始的な手作業(トイル)に後退するのはエンジニアリングとして美しくありません。
    自律型AIが本番環境を壊すのが怖いなら、『AIエージェント専用の完全に隔離されたサンドボックス環境(エフェメラル環境)』を自動で立ち上げ、そこで自由にコードを書かせてテストまで実行させればいいのです。IAM権限もその環境限定に絞ればセキュリティリスクはゼロです。
    『人間の手作業』をセキュリティの担保にするのは、現代のDevOpsの思想(すべてをコード化し自動化する)に逆行しています」

3. AI / LLMリサーチャーの視点

「『Lost in the Middle(中間の消失)』問題を甘く見すぎている」

  • 反論の要点: 超長文プロンプトにおけるAIの精度低下(精度劣化の暴力性)
  • 詳細: 「Gemini 3.1 Proが200万トークン入るからといって、『全部繋げて丸投げすれば完璧に理解する』というのはLLMの特性を誤解しています。 巨大なテキストを一度に読み込ませた場合、LLMはプロンプトの最初と最後はよく覚えていますが、真ん中あたりにある重要な情報(例えば独自フレームワークの細かい仕様など)を見落とす確率が飛躍的に高まります(Lost in the Middle現象)。ノイズ(無関係なコード)が多ければ多いほど、AIの推論精度やハルシネーション(嘘)の確率は悪化します。
    MCPやAST解析が『必要な部分だけをピンポイントで抽出する』のは、トークン節約のためだけではなく、『AIに最もノイズの少ない、純度の高い情報(Signal)だけを与えて推論精度を最大化するため』です。全部入りパックは、一見確実に見えて、実はAIのパフォーマンスを落としています」

最後に:自分の戦場に合った「武器」を選ぼう

このように、ゼロイチの新規開発や個人開発であれば、利用枠と戦ってでも自律型エージェントを使うメリットは十分にあります。しかし、複雑な環境定義を持つエンタープライズや、強固なセキュリティが求められる現場では、「人間MCP」による確実なコンテキスト統制こそが最高効率を叩き出します。

重要なのは、「あの有名エンジニアが推しているから」「タイムラインで流行っているから」という理由でツールを選ぶのではなく、「自分の今のプロジェクト(戦場)には、カット野菜パックが合うのか、それとも包丁から握るべきなのか」を見極めることです。

環境の壁に嘆くのではなく、目の前の制約の中でAIの力を最大化する。
この記事が、流行に疲れたエンジニアの皆様にとって、自分なりの「生存戦略」を見つけるヒントになれば幸いです。

ファイル結合ツールを使ったAIへの引き継ぎ手順

  1. 結合ツールにプロジェクトフォルダをドラッグ&ドロップし、テキストファイルを生成する。
  2. (ファイルが分割された場合)AIに対し、「これからソースを分割して送るので『OK』とだけ返して」と指示し、順次読み込ませる。
  3. 全て渡し終えたら、「完了。仕様書と上記のコードを理解した上で改修を行って」と指示を出す。

【文字列結合ツールのソース】

import tkinter as tk
import tkinter.ttk as ttk
from tkinter import filedialog, messagebox, simpledialog
from tkinterdnd2 import TkinterDnD, DND_FILES
import os
import json
import re
# 文字コード判定ライブラリをインポート
# 事前にインストールが必要です: pip install chardet
import chardet

# 設定ファイル名
SETTINGS_FILE = "file_concatenator_settings.json"

class FileConcatenatorApp(TkinterDnD.Tk):
    def __init__(self):
        # TkinterDnD.Tk を継承
        super().__init__()
        self.title("ファイル内容結合ツール")
        # ウィンドウサイズを設定
        self.geometry("600x550")

        # ドロップされたファイルのリストを保持
        self.dropped_files = []
        # 出力先ファイルのパスを保持
        self.output_path = ""
        # 最後に選択した出力ディレクトリを保持
        self.last_output_dir = ""
        # 最大出力行数
        self.max_lines_per_file = 8000 # デフォルト値

        # 設定の読み込み
        self.load_settings()

        # GUI部品の作成と配置
        self.create_widgets()
        # ドラッグ&ドロップ設定
        self.setup_dnd()

        # 読み込んだ設定をGUIに反映
        self.update_gui_from_settings()

    def load_settings(self):
        """設定ファイルを読み込む"""
        if os.path.exists(SETTINGS_FILE):
            try:
                # UTF-8エンコーディングで設定ファイルを読み込み
                with open(SETTINGS_FILE, 'r', encoding='utf-8') as f:
                    settings = json.load(f)
                    # 'last_output_dir' キーが存在すればその値を、なければ空文字列を使用
                    self.last_output_dir = settings.get('last_output_dir', '')
                    # 'dropped_files' キーが存在すればその値を、なければ空リストを使用
                    # ファイルが存在するかチェックしてからリストに追加
                    loaded_files = settings.get('dropped_files', [])
                    self.dropped_files = [f for f in loaded_files if os.path.isfile(f)]

                    # 'output_path' キーが存在すればその値を、なければ空文字列を使用
                    self.output_path = settings.get('output_path', '')

                    # 'max_lines_per_file' キーが存在すればその値を、なければデフォルト値を使用
                    self.max_lines_per_file = settings.get('max_lines_per_file', 8000)

            except (json.JSONDecodeError, FileNotFoundError):
                # JSONデコードエラーやファイルが見つからない場合は各設定を初期値にする
                self.last_output_dir = ""
                self.dropped_files = []
                self.output_path = ""
                self.max_lines_per_file = 8000

    def save_settings(self):
        """設定ファイルを保存する"""
        settings = {
            'last_output_dir': self.last_output_dir,
            'dropped_files': self.dropped_files,
            'output_path': self.output_path,
            'max_lines_per_file': self.max_lines_per_file
        }
        try:
            # UTF-8エンコーディングで設定ファイルを書き込み
            with open(SETTINGS_FILE, 'w', encoding='utf-8') as f:
                json.dump(settings, f, indent=4)
        except IOError:
            # 保存に失敗してもアプリの動作に致命的ではないため、エラー表示は行わない
            pass

    def update_gui_from_settings(self):
        """読み込んだ設定をGUIに反映する"""
        self.files_listbox.delete(0, tk.END)
        for f_path in self.dropped_files:
            self.files_listbox.insert(tk.END, os.path.basename(f_path))

        if self.output_path:
            self.output_path_entry.config(state='normal')
            self.output_path_entry.delete(0, tk.END)
            self.output_path_entry.insert(0, self.output_path)
            self.output_path_entry.config(state='readonly')

        self.max_lines_entry.config(state='normal')
        self.max_lines_entry.delete(0, tk.END)
        self.max_lines_entry.insert(0, str(self.max_lines_per_file))
        self.max_lines_entry.config(state='readonly')

        self.status_label.config(text=f"{len(self.dropped_files)}個のファイルがリストにあります")

    def create_widgets(self):
        """GUI部品を作成・配置する"""
        self.drop_label = ttk.Label(self, text="ここにファイルやフォルダをドラッグ&ドロップして追加", relief="solid",
                                    justify="center", padding=15, background="#cceeff",
                                    borderwidth=2)
        self.drop_label.pack(pady=10, padx=10, fill="x")

        listbox_frame = ttk.Frame(self)
        listbox_frame.pack(pady=5, padx=10, fill="both", expand=True)

        scrollbar = ttk.Scrollbar(listbox_frame)
        scrollbar.pack(side=tk.RIGHT, fill=tk.Y)

        self.files_listbox = tk.Listbox(listbox_frame, yscrollcommand=scrollbar.set)
        self.files_listbox.pack(side=tk.LEFT, fill="both", expand=True)
        scrollbar.config(command=self.files_listbox.yview)

        list_buttons_frame = ttk.Frame(self)
        list_buttons_frame.pack(pady=5, padx=10, fill="x")

        self.clear_list_button = ttk.Button(list_buttons_frame, text="リストをクリア", command=self.clear_files_list)
        self.clear_list_button.pack(side="left", padx=5)

        max_lines_frame = ttk.Frame(self)
        max_lines_frame.pack(pady=5, padx=10, fill="x")

        self.max_lines_label = ttk.Label(max_lines_frame, text="最大出力行数:")
        self.max_lines_label.pack(side="left")

        self.max_lines_entry = ttk.Entry(max_lines_frame, width=10, state='readonly')
        self.max_lines_entry.pack(side="left", padx=5)

        self.set_max_lines_button = ttk.Button(max_lines_frame, text="変更", command=self.set_max_lines)
        self.set_max_lines_button.pack(side="left")

        output_frame = ttk.Frame(self)
        output_frame.pack(pady=5, padx=10, fill="x")

        self.output_label = ttk.Label(output_frame, text="出力先ファイル:")
        self.output_label.pack(side="left")

        self.output_path_entry = ttk.Entry(output_frame, state='readonly')
        self.output_path_entry.pack(side="left", fill="x", expand=True, padx=5)

        self.select_output_button = ttk.Button(output_frame, text="選択", command=self.select_output_file)
        self.select_output_button.pack(side="left")

        self.concatenate_button = ttk.Button(self, text="ファイルを結合", command=self.concatenate_files)
        self.concatenate_button.pack(pady=10)

        self.status_label = ttk.Label(self, text="準備完了", anchor="center")
        self.status_label.pack(pady=5, padx=10, fill="x")

        self.files_listbox.bind('<Delete>', self.on_delete_key)

    def setup_dnd(self):
        """ドラッグ&ドロップ設定"""
        self.drop_label.drop_target_register(DND_FILES)
        self.drop_label.dnd_bind('<<Drop>>', self.on_drop)

    def on_drop(self, event):
        """ファイルがドロップされた時の処理"""
        paths_str = event.data
        paths = re.findall(r'[^ ]+|{[^}]+}', paths_str)

        processed_paths = []
        for path in paths:
            if path.startswith('{') and path.endswith('}'):
                processed_paths.append(path[1:-1])
            else:
                processed_paths.append(path)

        added_count = 0
        for path in processed_paths:
            if os.path.isdir(path):
                for root, _, filenames in os.walk(path):
                    for filename in filenames:
                        file_path = os.path.join(root, filename)
                        if file_path not in self.dropped_files:
                            self.dropped_files.append(file_path)
                            self.files_listbox.insert(tk.END, os.path.basename(file_path))
                            added_count += 1
            elif os.path.isfile(path):
                if path not in self.dropped_files:
                    self.dropped_files.append(path)
                    self.files_listbox.insert(tk.END, os.path.basename(path))
                    added_count += 1

        if added_count > 0:
            self.status_label.config(text=f"{added_count}個のファイルを追加しました ({len(self.dropped_files)}個のファイルがリストにあります)")
            self.save_settings()
        else:
            self.status_label.config(text=f"追加されたファイルはありませんでした ({len(self.dropped_files)}個のファイルがリストにあります)")

    def clear_files_list(self):
        """ファイルリストをクリアする"""
        if messagebox.askyesno("確認", "ファイルリストをすべてクリアしますか?"):
            self.files_listbox.delete(0, tk.END)
            self.dropped_files = []
            self.status_label.config(text="ファイルリストをクリアしました")
            self.save_settings()

    def on_delete_key(self, event):
        """リストボックスでDeleteキーが押された時の処理"""
        selected_indices = self.files_listbox.curselection()
        if not selected_indices:
            return

        for index in sorted(selected_indices, reverse=True):
            self.files_listbox.delete(index)
            if 0 <= index < len(self.dropped_files):
                del self.dropped_files[index]

        self.status_label.config(text=f"{len(selected_indices)}個のファイルをリストから削除しました ({len(self.dropped_files)}個のファイルがリストにあります)")
        self.save_settings()

    def set_max_lines(self):
        """最大出力行数を設定するダイアログを表示"""
        current_value = self.max_lines_per_file
        new_value_str = simpledialog.askstring("最大行数設定", "最大出力行数を入力してください:",
                                               initialvalue=str(current_value))
        if new_value_str is not None:
            try:
                new_value = int(new_value_str)
                if new_value > 0:
                    self.max_lines_per_file = new_value
                    self.max_lines_entry.config(state='normal')
                    self.max_lines_entry.delete(0, tk.END)
                    self.max_lines_entry.insert(0, str(self.max_lines_per_file))
                    self.max_lines_entry.config(state='readonly')
                    self.status_label.config(text=f"最大出力行数を {self.max_lines_per_file} に設定しました")
                    self.save_settings()
                else:
                    messagebox.showwarning("不正な入力", "1以上の整数を入力してください。")
            except ValueError:
                messagebox.showwarning("不正な入力", "有効な整数を入力してください。")

    def select_output_file(self):
        """出力先ファイルを選択するダイアログを表示"""
        initial_dir = self.last_output_dir if self.last_output_dir and os.path.isdir(self.last_output_dir) else os.getcwd()
        output_file = filedialog.asksaveasfilename(
            title="結合結果を保存",
            initialdir=initial_dir,
            defaultextension=".txt",
            filetypes=[("テキストファイル", "*.txt"), ("すべてのファイル", "*.*")]
        )
        if output_file:
            self.output_path = output_file
            self.output_path_entry.config(state='normal')
            self.output_path_entry.delete(0, tk.END)
            self.output_path_entry.insert(0, self.output_path)
            self.output_path_entry.config(state='readonly')
            self.last_output_dir = os.path.dirname(self.output_path)
            self.save_settings()

    # --- ▼▼▼ ここからが修正箇所 ▼▼▼ ---

    def detect_encoding(self, file_path):
        """
        chardetライブラリを使用してファイルの文字コードを判定する。
        """
        try:
            with open(file_path, 'rb') as f:
                # ファイル全体を読み込んで判定精度を上げる
                raw_data = f.read()
                # ファイルが空の場合は判定しない
                if not raw_data:
                    return 'utf-8' # デフォルト
                result = chardet.detect(raw_data)
                encoding = result['encoding']
                
                # 判定結果がNoneの場合や信頼性が低い場合はUTF-8をフォールバックとして使用
                if encoding is None or result['confidence'] < 0.7:
                    return 'utf-8'

                # Shift_JIS系のエンコーディングは、より互換性の高いcp932に統一
                if encoding.lower() in ['shift_jis', 'windows-31j']:
                    return 'cp932'
                
                return encoding
        except (FileNotFoundError, Exception):
            # 何らかのエラーが発生した場合もUTF-8を返す
            return 'utf-8'

    def get_next_output_filename(self, base_path, index):
        """連番付きの出力ファイル名を生成する"""
        if index == 0:
            return base_path

        dirname, basename = os.path.split(base_path)
        name, ext = os.path.splitext(basename)

        # 連番を3桁ゼロ埋めで生成
        numbered_name = f"{name}_{index:03d}{ext}"
        return os.path.join(dirname, numbered_name)

    def concatenate_files(self):
        """
        ファイルの結合を実行。
        各ファイルの文字コードを自動判定し、UTF-8に変換して結合する。
        """
        if not self.dropped_files:
            messagebox.showwarning("警告", "結合するファイルがドロップされていません。")
            return
        if not self.output_path:
            messagebox.showwarning("警告", "出力先ファイルが選択されていません。")
            return
        if not isinstance(self.max_lines_per_file, int) or self.max_lines_per_file <= 0:
            messagebox.showwarning("警告", "最大出力行数が正しく設定されていません。")
            return

        current_output_file = None
        try:
            self.status_label.config(text="結合中...")
            self.update()

            output_file_index = 0
            current_lines_in_output = 0

            for infile_path in self.dropped_files:
                try:
                    # 1. 文字コードを判定
                    encoding = self.detect_encoding(infile_path)
                    
                    # 2. 判定した文字コードでファイル内容を読み込む
                    #    デコードエラーが発生した場合は'?'で置換する
                    with open(infile_path, 'r', encoding=encoding, errors='replace') as infile:
                        content = infile.read()
                    
                    # 3. 読み込んだ内容から行数を計算
                    infile_lines = content.count('\n')
                    if content and not content.endswith('\n'):
                        infile_lines += 1

                    # 4. 新しい出力ファイルが必要か判断
                    #    出力ファイルが既に開かれており、現在の行数と追加する行数が最大値を超える場合
                    if (current_output_file is not None and 
                        current_lines_in_output > 0 and 
                        (current_lines_in_output + infile_lines) > self.max_lines_per_file):
                        
                        current_output_file.close()
                        current_output_file = None
                        output_file_index += 1
                        current_lines_in_output = 0
                        self.status_label.config(text=f"最大行数を超過。新しいファイルを作成します...")
                        self.update()

                    # 5. 出力ファイルを開く
                    if current_output_file is None:
                        output_filename = self.get_next_output_filename(self.output_path, output_file_index)
                        # 出力はUTF-8で統一
                        current_output_file = open(output_filename, 'w', encoding='utf-8')
                        self.status_label.config(text=f"出力ファイル: {os.path.basename(output_filename)}")
                        self.update()

                    # 6. ヘッダー、ファイル内容、フッターを出力ファイルに書き込む
                    header_line = f"--- ファイル開始: {infile_path} ---\n"
                    separator_line = "-" * 30 + "\n"
                    footer_line = f"--- ファイル終了: {os.path.basename(infile_path)} ---\n"
                    
                    current_output_file.write(header_line)
                    current_output_file.write(separator_line)
                    current_output_file.write(content)
                    
                    # 内容の末尾が改行でない場合は改行を追加
                    if not content.endswith('\n'):
                        current_output_file.write('\n')
                    
                    current_output_file.write(separator_line)
                    current_output_file.write(footer_line)
                    current_output_file.write("\n") # ファイル間のスペース

                    # 書き込んだ行数を加算
                    current_lines_in_output += (infile_lines + 5) # ヘッダー(1) + 区切り(1) + 内容 + 区切り(1) + フッター(1) + 空行(1)

                    self.status_label.config(text=f"'{os.path.basename(infile_path)}' を結合しました ({current_lines_in_output} / {self.max_lines_per_file} 行)")
                    self.update()

                except FileNotFoundError:
                    messagebox.showerror("エラー", f"入力ファイルが見つかりません:\n{os.path.basename(infile_path)}")
                    continue # 次のファイルへ
                except Exception as e:
                    messagebox.showerror("エラー", f"ファイル読み込み中に問題が発生しました:\n{os.path.basename(infile_path)}\n{e}")
                    continue # 次のファイルへ

            self.status_label.config(text=f"結合完了。{output_file_index + 1}個のファイルに出力しました。")
            messagebox.showinfo("成功", f"ファイルは正常に結合されました。\n出力先: {os.path.dirname(self.output_path)}")

        except PermissionError:
            self.status_label.config(text="エラー: 出力ファイルへの書き込み権限がありません。")
            messagebox.showerror("エラー", "出力ファイルへの書き込み権限がありません。\n指定したファイルが他のアプリケーションで開かれていないか確認してください。")
        except Exception as e:
            self.status_label.config(text=f"エラー: 結合中に予期せぬ問題が発生しました ({e})")
            messagebox.showerror("エラー", f"結合中に予期せぬ問題が発生しました:\n{e}")
        finally:
            if current_output_file and not current_output_file.closed:
                current_output_file.close()
    
    # --- ▲▲▲ ここまでが修正箇所 ▲▲▲ ---

    def on_closing(self):
        """ウィンドウを閉じる時の処理"""
        self.save_settings()
        self.destroy()

if __name__ == "__main__":
    app = FileConcatenatorApp()
    app.protocol("WM_DELETE_WINDOW", app.on_closing)
    app.mainloop()

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?