diff --git a/internal/extract/html_test.go b/internal/extract/html_test.go
index 35548f0..d800d58 100644
--- a/internal/extract/html_test.go
+++ b/internal/extract/html_test.go
@@ -149,6 +149,36 @@ func TestMatchSection_Unknown(t *testing.T) {
}
}
+// TestMatchSection_NakaguroVariants confirms tolerant matching across the
+// 中黒 (・) variations seen in real EDINET filings. e.g., 株式会社セブン&
+// アイ・ホールディングス 第20期 (S100VT7P) uses「コーポレートガバナンス」
+// (no middle dot) while 日本マクドナルドホールディングス 第55期 (S100XS22)
+// uses「コーポレート・ガバナンス」 (with middle dot). Both must map to the
+// governance section.
+func TestMatchSection_NakaguroVariants(t *testing.T) {
+ cases := []struct {
+ heading string
+ wantID string
+ }{
+ {"4【コーポレート・ガバナンスの状況等】", "governance"},
+ {"4【コーポレートガバナンスの状況等】", "governance"},
+ {"(1)【コーポレート・ガバナンスの概要】", "governance"},
+ {"(1)【コーポレートガバナンスの概要】", "governance"},
+ {"4【コーポレート ガバナンスの状況等】", "governance"}, // half-width space
+ {"4【コーポレート ガバナンスの状況等】", "governance"}, // full-width space
+ }
+ for _, c := range cases {
+ got := MatchSection(c.heading)
+ if got == nil {
+ t.Errorf("MatchSection(%q) = nil, want id=%q", c.heading, c.wantID)
+ continue
+ }
+ if got.ID != c.wantID {
+ t.Errorf("MatchSection(%q).ID = %q, want %q", c.heading, got.ID, c.wantID)
+ }
+ }
+}
+
// TestExtractSections_BleedAcrossUnknownHeadings reproduces the bleed-truncated
// pattern observed in EDINET filings such as docID S100XS22 (日本マクドナルド
// HD 第55期): the "従業員の状況" section is followed by unknown headings
diff --git a/internal/extract/sections.go b/internal/extract/sections.go
index a6c2df9..b9a1ca8 100644
--- a/internal/extract/sections.go
+++ b/internal/extract/sections.go
@@ -29,11 +29,34 @@ var KnownSections = []SectionDef{
{ID: "dividends", Names: []string{"配当政策"}},
}
+// normalizeForMatch normalizes a string for tolerant heading comparison.
+// EDINET 提出企業ごとに 中黒 (・) の有無や前後の空白が揺れているため、
+// マッチング前に正規化する。 例: 株式会社セブン&アイ・ホールディングス は
+// 「コーポレートガバナンス」 (中黒なし)、 日本マクドナルドホールディングス
+// は「コーポレート・ガバナンス」 (中黒あり) で同じ章を表す。
+func normalizeForMatch(s string) string {
+ // Remove the katakana middle dot (・, U+30FB) and ASCII / full-width
+ // whitespace so headings that differ only in these decorative chars still
+ // match the same KnownSections entry.
+ r := strings.NewReplacer(
+ "・", "",
+ " ", "",
+ " ", "",
+ "\t", "",
+ "\n", "",
+ "\r", "",
+ )
+ return r.Replace(s)
+}
+
// MatchSection returns the SectionDef matching the given heading text, or nil if none match.
+// Comparison is performed on a normalized form so that minor variations in
+// 中黒 (・) usage and whitespace do not cause false negatives.
func MatchSection(heading string) *SectionDef {
+ normHeading := normalizeForMatch(heading)
for i := range KnownSections {
for _, name := range KnownSections[i].Names {
- if strings.Contains(heading, name) {
+ if strings.Contains(normHeading, normalizeForMatch(name)) {
return &KnownSections[i]
}
}