ZIPで渡す商品のREADME、相対リンクを確かめる

ZIPを開いた購入者は、最初にREADMEを読みます。素材一覧へのリンクがあっても、リンク先が入っていなければそこで止まります。AIに説明文の下書きを手伝わせるほど、文章の更新は速くなります。その速さを、配布物の確認漏れにしないための小さな見本を作りました。
前回は、同じ内容のZIPでも作成日時や格納順でハッシュが変わることを見ました(配布ZIPの中身は同じなのにハッシュが変わる)。今回はその次です。アーカイブ自体が読めても、案内文が中のファイルを指せるかは別に確かめます。
ここで扱うのは実際の販売物ではありません。週末の献立テンプレートを想定し、README.md、docs/START.md、LICENSE.txt、CSVの4ファイルを作った合成見本です。AIがこの見本を生成したわけでも、販売結果を測ったわけでもありません。
リンクはREADMEの場所から数える
Markdownのリンク先がassets/catalog.csvなら、起点はREADME自身の置き場所です。docs/START.mdから../assets/catalog.csvへ進む場合は、いったんdocsを1段戻ります。この..を文字列のままにすると、ZIPには存在するファイルを「壊れている」と誤判定します。
Webのアドレスの決まり(RFC 3986)にも、リンクを書いたページの場所を起点にして、.や..(今の場所・1つ上の場所を表す部分)を整理する手順があります。ZIP内のMarkdownはWebページそのものではありませんが、「どのファイルから数えるか」と「..をどう整理するか」を分けて考えると混乱しません。
[README.md] ── assets/catalog.csv ──> [assets/catalog.csv]
│
├── LICENSE.txt ───────────────> [LICENSE.txt]
│
└── docs/START.md ── ../assets/catalog.csv ──> [assets/catalog.csv]
4ファイルだけの見本を作る
Python 3.13.5で、下のスクリプトをcheck_zip_markdown_links.pyとして保存し、見本のディレクトリをZIPへ収め、Markdownのインラインリンクを数えました。外部URLと同じページ内のアンカーは、この確認の対象から外しています。配布物の中にある相対リンクだけを見ます。
実行したスクリプトを開く
from __future__ import annotations
import posixpath
import re
import shutil
import zipfile
from pathlib import Path, PurePosixPath
root = Path("zip-link-sample")
if root.exists():
shutil.rmtree(root)
(root / "assets").mkdir(parents=True)
(root / "README.md").write_text(
"# 週末の献立テンプレート\n\n"
"[使い方](docs/START.md) と [利用条件](LICENSE.txt) を先に読みます。\n"
"素材の一覧は [CSV](assets/catalog.csv) です。\n",
encoding="utf-8",
)
(root / "docs").mkdir()
(root / "docs" / "START.md").write_text(
"# 使い方\n\n[素材一覧](../assets/catalog.csv) を確認します。\n",
encoding="utf-8",
)
(root / "LICENSE.txt").write_text("個人利用の見本です。\n", encoding="utf-8")
(root / "assets" / "catalog.csv").write_text("name,format\nmeal-plan,pdf\n", encoding="utf-8")
archive = Path("meal-plan-sample.zip")
with zipfile.ZipFile(archive, "w", zipfile.ZIP_DEFLATED) as zf:
for item in sorted(root.rglob("*")):
if item.is_file():
zf.write(item, item.as_posix())
link = re.compile(r"\[[^]]+\]\(([^)]+)\)")
issues = []
checked = 0
with zipfile.ZipFile(archive) as zf:
members = set(zf.namelist())
for name in sorted(members):
if not name.endswith(".md"):
continue
for target in link.findall(zf.read(name).decode("utf-8")):
if "://" in target or target.startswith("#"):
continue
candidate = (PurePosixPath(name).parent / target).as_posix()
normalized = posixpath.normpath(candidate)
checked += 1
if normalized not in members:
issues.append(f"broken {name} -> {target} ({normalized})")
print(f"archive={archive.name}")
print(f"members={len(members)}")
print(f"markdown_links_checked={checked}")
print(f"broken_links={len(issues)}")
for issue in issues:
print(issue)
mkdir -p "$TMPDIR/zip-link-check"
cd "$TMPDIR/zip-link-check"
表示はありません。
このディレクトリに、上のスクリプトをcheck_zip_markdown_links.pyとして保存します。
python3 check_zip_markdown_links.py
python3 -m zipfile -t meal-plan-sample.zip
archive=meal-plan-sample.zip
members=4
markdown_links_checked=4
broken_links=0
Done testing
zipfileはZIPの作成、読み取り、一覧取得を扱えます。ここではアーカイブを一度作り、namelist()で得た名前と、READMEから計算した相対パスを比べました。python3 -m zipfile -tのDone testingは、ZIPとしての読取り検査が終わった表示です。リンク先の意味や商品の内容まで保証するものではありません。
最初の実装は正しいリンクを壊れたことにした
最初はPurePosixPathでdocs/START.mdと../assets/catalog.csvをつなげるだけにしました。normpathを入れる前の1行は次のとおりです。
- normalized = candidate
+ normalized = posixpath.normpath(candidate)
結果はzip-link-sample/docs/../assets/catalog.csvです。アーカイブ内の名前はzip-link-sample/assets/catalog.csvなので、一致せず、壊れたリンク1件と出ました。
archive=meal-plan-sample.zip
members=4
markdown_links_checked=4
broken_links=1
broken zip-link-sample/docs/START.md -> ../assets/catalog.csv (zip-link-sample/docs/../assets/catalog.csv)
リンク先のCSVは入っていました。間違っていたのは見本ではなく、点検側です。posixpath.normpath()で..を整理してから照合すると、4件すべてが見つかり、壊れたリンクは0件になりました。私は、判定器が出した赤い表示をそのまま信じず、実際のアーカイブ名と比較する工程を残したいと思います。
用語
| 用語 | この見本での意味 |
|---|---|
| 相対リンク | 現在のMarkdownファイルを起点に書くリンク先です |
| 基準ファイル | リンクを書いたMarkdownファイルです。ここではREADME.mdやdocs/START.mdです |
| ドット区間 | .や..のように、現在または親の場所を表すパスの部分です |
| 正規化 | docs/../assetsのような表記を、同じ場所を表す短い表記へ整理することです |
| ZIPメンバー | ZIP内に格納された個々のファイル名です |
| 誤検知 | 実際には問題がないものを、問題ありと判定することです |
販売前に残す境界
この確認はMarkdownの単純なインラインリンクだけを数えます。画像、HTML、参照形式リンク、URLエンコード、リンク先に空白や括弧を含むケースは扱いません。../がZIPの外を指すリンクは、販売前のルールとして拒否します。
また、リンクが開けても説明が十分とは限りません。返金条件、対応環境、利用範囲、更新方法などをREADMEに書くかは、商品と販売先の規約で決めます。今回の4ファイルは、それらを満たす商品見本ではありません。
AIで説明文を増やす前に、案内先をファイルとして確認する。小さな順番ですが、購入後の「どこを見ればよいですか」を減らす土台になります。
出典
- Python公式文書: zipfile(ZIPアーカイブの作成、読取り、メンバー一覧と検査)
- Python公式文書: os.path.normpath(パス中の
..を含む冗長な区間を整理する動作) - RFC 3986: Uniform Resource Identifier (URI): Generic Syntax(相対参照の解決とドット区間の除去)


