CICの情報開示PDFを解析してCSV化したりサマったり

# はじめに

クレカ・カードローンをはじめ、住宅ローン・携帯の分割払いなど「借金」の情報を集約するCICという信用情報機関があります。

自分の信用情報がどう登録されているのか、情報開示を行うとPDFでくれます。（500円＋ナビダイヤル代100円ぐらいかかります。）

2024年11月28日から、「クレジット・ガイダンス」の提供が開始され、注目が集まりました。

Twitter(自称X)では [#信用スコアバトル](https://x.com/search?q=%23%E4%BF%A1%E7%94%A8%E3%82%B9%E3%82%B3%E3%82%A2%E3%83%90%E3%83%88%E3%83%AB) というハッシュタグで盛り上がっています。

私もそれを機に情報開示請求をしてみました。

PDFはカードごとに1ページになってます。件数が多いと非常に見にくいです。たとえば、枠の合計、残債の合計などをしようと思うと途中で心が折れます。

そこで、PDFを解析してCSVにすることにしました。

さらに、枠の合計、残債の合計、$の件数などなど、クレジット・ガイダンスの算出もとになるであろう数値も出すようにしました。

# 技術的な話

PDFの解析は Python + pdfminer を採用しました。

CICのPDFは難読化を意識しているのか、pdfminer で解析して出てくる順番がページによってバラバラです。たとえば、ページごとの「クレジット情報」みたいな文字列は必ず先頭にありそうなものですが、先頭でない場合もあります。

（難読化のためじゃなくて、単に順序保証しない連想配列を使ってるだけかもしれませんが）

そこで、座標をみて、ココにあるからこのデータ、みたいな解析方法にしました。

左寄せならX,Y座標が固定になるのですが、中央寄せ・右寄せの場合もあるので、X方向は範囲で特定するようにしています。（Y座標はブレないので固定です）

# ソースコード

Qiitaに載せるサイズじゃないような気もしますが、外部に置くのも面倒なので、ここに貼ります。

```summarize_cic.py
#########################################################################
### CIC情報開示のPDFをCSVなどに変換するプログラム
### 
### 環境：
###   Python 3.12.7 (3.7以降ぐらいかな)
###   pdfminer-20191125
###   python-dateutil-2.9.0.post0
### 
### 出力ファイル： 
###   0_表紙.txt
###   1_クレジット情報.csv
###   2_申込情報.csv
###   3_利用記録.csv
###   4_クレジット・ガイダンス.txt
###   9_CICサマリ.txt
### 
### 使い方： 
###   下記「利用者が編集するところ」を編集して実行する
#########################################################################

###################### 利用者が編集するところ #############################
CIC_PDF_PATH = "cicservice12051132.pdf"
CIC_PDF_PASSWORD = b"5256060000"
OUTPUT_PATH = "./"

# CSV出力内容の ON/OFF
ENABLE_NUM = False           # No. を出力する/しない
ENABLE_PERSONAL_INFO = False # 個人情報を出力する/しない
ENABLE_NULL_COL = False      # 全行で空欄のカラムを出力する/しない
#########################################################################

DEBUG_MODE = None # None or 'export' or 'import'

import re
import datetime
from dateutil.relativedelta import relativedelta

from dataclasses import dataclass

from pdfminer.pdfparser import PDFParser
from pdfminer.pdfdocument import PDFDocument
from pdfminer.pdfpage import PDFPage
from pdfminer.pdfinterp import PDFResourceManager
from pdfminer.pdfinterp import PDFPageInterpreter
from pdfminer.converter import PDFPageAggregator
from pdfminer.layout import (
    LAParams,
    LTContainer,
    LTTextLine,
)

# PDF解析結果の入れ物
@dataclass
class CicPdfData :
    coverDict = {} # 表紙
    creditDataList = [] # クレジット情報
    applicationDataList = [] # 申込情報
    usageDataList = [] # 利用記録
    scoreList = [] # クレジット・ガイダンス

# クレジット情報のサマリ（'13. 契約の内容' ごとにまとめた結果）
@dataclass
class CreditSummaryData :
    count_k = 0 # 件数
    count_z = 0 # 残債あり件数
    kyokudogaku = 0 # 極度額
    zansaigaku = 0 # 残債額

    # 以下クレカのみ使用。キャッシング枠
    count_kc = 0 # 件数
    count_zc = 0 # 残債あり件数
    kyokudogaku_c = 0 # 極度額
    zansaigaku_c = 0 # 残債額

# CICのサマリ
@dataclass
class CicSummary :
    creditSummaryDict = {
        'カード等':CreditSummaryData(),
        '個品割賦':CreditSummaryData(),
        'リース':CreditSummaryData(),
        '保証契約':CreditSummaryData(),
        '保証融資':CreditSummaryData(),
        '無保証融資':CreditSummaryData(),
        '住宅ローン':CreditSummaryData(),
        '移管債権':CreditSummaryData(),
    }
    joukyou_kensu_dict = {} # 状況の件数(keyは例の$など)
    moushikomi_kensu_dict = {} # 申込件数(keyは 10. 申込区分)
    kikanMinStr:str = '-'
    kikanMinStr:str = '-'
    kikanMinStr:str = '-'


# PDF解析クラス
class MyPdf :
    # 参考: https://qiita.com/mima_ita/items/d99afc28b6f51479f850
    # ↑を元にクラス化。正直、良く分かってない。
    def __init__(self):
        laparams = LAParams(
            all_texts=True,
        )
        rsrcmgr = PDFResourceManager()
        self._device = PDFPageAggregator(rsrcmgr, laparams=laparams)
        self._interpreter = PDFPageInterpreter(rsrcmgr, self._device)

    def __del__(self):
        if hasattr(self, "_f") :
            self._f.close()

    def open(self, path, password) :
        self._f = open(path, "rb")
        parser = PDFParser(self._f)
        document = PDFDocument(parser, password=password, fallback=False)

        self.pdfPages = PDFPage.create_pages(document)
        # 利用者はコレ↑をforで回す

    # self.pdfPages の中身を渡すと、テキストのListが返る。
    def parsePage(self, page) :
        self._interpreter.process_page(page)
        layout = self._device.get_result()
        results = []
        self._get_objs(layout, results)

        return results

    def _get_objs(self, layout, results):
        if not isinstance(layout, LTContainer):
            return
        for obj in layout:
            if isinstance(obj, LTTextLine):
                #results.append({'bbox': obj.bbox, 'text' : obj.get_text(), 'type' : type(obj)})
                results.append({'bbox': obj.bbox, 'text' : obj.get_text().replace('\u3000','　').strip()})
            self._get_objs(obj, results)

# 表紙 座標定義
COVER_DICT = {
    #(20.0, 472.0): '姓名〔カナ〕',
    (700.0, 564.0): '初回開示日時',
    (700.0, 552.5): '受付日',
    (700.0, 541.5): '受付番号',
    (636.0, 229.0): '受付番号を取得した電話番号',
    (636.0, 213.0): '姓〔カナ〕',
    (636.0, 197.0): '名〔カナ〕',
    (636.0, 181.0): '生年月日',
    (636.0, 165.0): '運転免許証番号',
    (636.0, 149.0): 'その他電話番号１',
    (636.0, 133.0): 'その他電話番号２',
    (636.0, 117.0): 'その他電話番号３',
    (636.0, 101.0): 'その他電話番号４',
    (636.0,  85.0): 'その他電話番号５',
    (636.0,  69.0): 'クレジット・ガイダンス',
}

#クレジット情報 座標定義
#(keyがy座標、中身はx1,x2(範囲)と項目名)
CREDIT_DICT = {
    #564.5: ((700.0,701.0,'受付日'),),
    #553.5: ((636.0,637.0,'受付番号'),),
    538.0: ((137.0,157.0,'No.'), (158.0,178.0,'totalNum'), (287.0,288.0,'1. 登録元会社'), (735.0,736.0,'2. 保有期限'),),

    505.0: ((134.0,550.0,'3. 氏名〔カナ〕'), (703.0,819.0,'32. 割賦残債額'),),
    491.0: ((134.0,550.0,'3. 氏名'), (703.0,819.0,'33. 年間請求予定額'),),
    477.0: ((134.0,330.0,'4. 生年月日'), (438.0,550.0,'5. 性別'), (703.0,819.0,'34. 支払遅延有無'),),
    463.0: ((134.0,550.0,'6. 電話番号'), (703.0,819.0,'34.(遅延発生日)'),),
    449.0: ((134.0,550.0,'7. 住所1'), (703.0,819.0,'34.(遅延解消日)'),),
    435.0: ((134.0,550.0,'7. 住所2'),),
    421.0: ((134.0,550.0,'8. 勤務先名'),),
    407.0: ((134.0,550.0,'9. 勤務先電話'), (703.0,819.0,'35. 確定日'),),
    393.0: ((328.0,550.0,'10. 公的資料1'), (703.0,819.0,'36. 残高'),),
    379.0: ((364.0,550.0,'10. 公的資料1（確認日）'), (703.0,819.0,'36.(内ｷｬｯｼﾝｸﾞ残高)'),),
    365.0: ((328.0,550.0,'10. 公的資料2'), (703.0,819.0,'37. 契約額'),),
    351.0: ((364.0,550.0,'10. 公的資料2（確認日）'), (703.0,819.0,'38. 極度額'),),
    337.0: ((134.0,550.0,'11. 配偶者名'), (703.0,819.0,'38.(内ｷｬｯｼﾝｸﾞ枠)'),),
    323.0: ((703.0,819.0,'39. 商品名'),),

    308.0: ((134.0,270.0,'12. 契約の種類'),       (438.0,550.0,'22. 報告日'),),
    294.0: ((134.0,270.0,'13. 契約の内容'),       (438.0,550.0,'23. 請求額'),),
    280.0: ((134.0,270.0,'14. 契約年月日'),       (438.0,550.0,'24. 入金額'),),
    266.0: ((134.0,270.0,'15. 契約終了予定日'),   (438.0,550.0,'25. 残債額'),),
    252.0: ((134.0,270.0,'16. 支払回数'),         (438.0,550.0,'25.(内ｷｬｯｼﾝｸﾞ残債額)'),),
    238.0: ((134.0,270.0,'17. 契約額'),           (438.0,550.0,'26. 返済状況'),),
    224.0: ((134.0,270.0,'18. 極度額'),           (438.0,550.0,'26.(異動発生日)'),),
    210.0: ((134.0,270.0,'18.(内ｷｬｯｼﾝｸﾞ枠)'),     (438.0,550.0,'27. 経過状況'),),
    196.0: ((134.0,270.0,'19. 商品名1'),          (438.0,550.0,'27.(経過状況発生日)'),),
    182.0: ((134.0,270.0,'19.(数量・回数・期間)'),(438.0,550.0,'28. 補足内容'),),
    168.0: ((134.0,270.0,'20. 商品名2'),          (438.0,550.0,'28.(延滞解消日)'),),
    154.0: ((134.0,270.0,'20.(数量・回数・期間)'),(438.0,550.0,'29. 保証履行額'),),
    140.0: ((134.0,270.0,'21. 商品名3'),          (438.0,550.0,'30. 金額'),),
    126.0: ((134.0,270.0,'21.(数量・回数・期間)'),(438.0,550.0,'31. 終了状況'),),

    309.0: ((703.0,819.0,'40. 貸付日'),),
    295.0: ((703.0,819.0,'41. 貸付額'),),
    281.0: ((703.0,819.0,'42. 出金額'),),
    267.0: ((703.0,819.0,'43. 最新支払日'),),
    253.0: ((703.0,819.0,'44. 次回支払予定日'),),
    239.0: ((703.0,819.0,'45. 遅延有無'),),
    225.0: ((703.0,819.0,'46. 担保・保証人有無'),),
    211.0: ((703.0,819.0,'47. 終了状況'),),

    54.0: ((66.0,86.0,'コメント'),),

    96.0: ((54.0,86.0,'状況年'),),
    82.0: ((54.0,86.0,'状況月'),),
}

#クレジット情報 ヘッダ定義
CREDIT_HEADER = (
    #"受付日","受付番号",
    "No.",
    "1. 登録元会社","2. 保有期限",
    "3. 氏名〔カナ〕","3. 氏名","4. 生年月日","5. 性別","6. 電話番号","7. 住所1","7. 住所2","8. 勤務先名","9. 勤務先電話","10. 公的資料1","10. 公的資料1（確認日）","10. 公的資料2","10. 公的資料2（確認日）","11. 配偶者名",
    "12. 契約の種類","13. 契約の内容","14. 契約年月日","15. 契約終了予定日","16. 支払回数","17. 契約額","18. 極度額","18.(内ｷｬｯｼﾝｸﾞ枠)","19. 商品名1","19.(数量・回数・期間)","20. 商品名2","20.(数量・回数・期間)","21. 商品名3","21.(数量・回数・期間)",
    "22. 報告日","23. 請求額","24. 入金額","25. 残債額","25.(内ｷｬｯｼﾝｸﾞ残債額)","26. 返済状況","26.(異動発生日)","27. 経過状況","27.(経過状況発生日)","28. 補足内容","28.(延滞解消日)","29. 保証履行額","30. 金額","31. 終了状況",
    "32. 割賦残債額","33. 年間請求予定額","34. 支払遅延有無","34.(遅延発生日)","34.(遅延解消日)",
    "35. 確定日","36. 残高","36.(内ｷｬｯｼﾝｸﾞ残高)","37. 契約額","38. 極度額","38.(内ｷｬｯｼﾝｸﾞ枠)","39. 商品名","40. 貸付日","41. 貸付額","42. 出金額","43. 最新支払日","44. 次回支払予定日","45. 遅延有無","46. 担保・保証人有無","47. 終了状況",
    "コメント",
    "状況年月",
    "状況1","状況2","状況3","状況4","状況5","状況6","状況7","状況8","状況9","状況10","状況11","状況12","状況13","状況14","状況15","状況16","状況17","状況18","状況19","状況20","状況21","状況22","状況23","状況24")

#申込情報 座標定義
APPLICATION_DICT = {
    503.0: ((58.0,77.0,'No.'), (78.0,98.0,'totalNum'), (212.0,423.0,'1. 登録元会社'), (732.0,821.0,'2. 保有期限'),),
    489.0: ((135.0,422.0,'3. 氏名'), (535.0,821.0,'10. 申込区分'),),
    475.0: ((135.0,422.0,'4. 生年月日'), (535.0,821.0,'11. 契約予定額'),),
    461.0: ((135.0,422.0,'5. 郵便番号'), (535.0,821.0,'12. 支払予定回数'),),
    447.0: ((135.0,422.0,'6. 電話番号１'), (535.0,671.0,'13. 商品名（１）'), (790.0,821.0,'17. 数量・回数・期間（１）'),),
    433.0: ((135.0,422.0,'7. 電話番号２'), (535.0,671.0,'14. 商品名（２）'), (790.0,821.0,'18. 数量・回数・期間（２）'),),
    419.0: ((135.0,422.0,'8. 照会日時'), (535.0,671.0,'15. 商品名（３）'), (790.0,821.0,'19. 数量・回数・期間（３）'),),
    405.0: ((135.0,422.0,'9. 照会区分'), (535.0,671.0,'16. 商品名（４）'),),
}

#申込情報 ヘッダ定義
APPLICATION_HEADER = (
    'No.','1. 登録元会社','2. 保有期限',
    '3. 氏名','4. 生年月日','5. 郵便番号','6. 電話番号１','7. 電話番号２','8. 照会日時','9. 照会区分',
    '10. 申込区分','11. 契約予定額','12. 支払予定回数','13. 商品名（１）','14. 商品名（２）','15. 商品名（３）','16. 商品名（４）',
    '17. 数量・回数・期間（１）','18. 数量・回数・期間（２）','19. 数量・回数・期間（３）',
)

#利用記録 座標定義
USAGE_DICT = {
    504.0: ((137.0,400.0,'1. 登録元会社'),),
    490.0: ((58.0,78.0,'No.'), (79.0,99.0,'totalNum'), (137.0,400.0,'2. 保有期限'),),
    476.0: ((136.0,400.0,'3. 氏名'), ),
    462.0: ((136.0,400.0,'4. 生年月日'), ),
    448.0: ((136.0,400.0,'5. 照会日時'), ),
    434.0: ((136.0,400.0,'6. 利用目的'), ),
}

#利用記録 ヘッダ定義
USAGE_HEADER = (
    'No.','1. 登録元会社','2. 保有期限',
    '3. 氏名','4. 生年月日','5. 照会日時','6. 利用目的',
)

DEPOSIT_STATUS_DICT = {
    '$': '請求どおり（もしくは、それ以上）の入金があった',
    'P': '請求額の一部が入金された',
    'R': 'お客様以外から入金があった',
    'A': 'お客様の事情でお約束の日に入金がなかった（未入金）',
    'B': 'お客様の事情とは無関係の理由で入金がなかった',
    'C': '入金されていないが、その原因がわからない',
    '-': '請求もなく入金もなかった（例：ｸﾚｼﾞｯﾄの利用がない場合）',
    ' ': 'クレジット会社等から情報の更新がなかった（例：ｸﾚｼﾞｯﾄの利用がない場合）',
}

#クレジット・ガイダンス 座標定義
# →ハードコーディング

#個人情報の列名定義(クレジット情報,申込情報,利用記録まぜこぜ)
PERSONAL_INFO_KEY_LIST = (
    "3. 氏名〔カナ〕","3. 氏名","4. 生年月日","5. 性別","6. 電話番号","7. 住所1","7. 住所2","8. 勤務先名","9. 勤務先電話","10. 公的資料1","10. 公的資料1（確認日）","10. 公的資料2","10. 公的資料2（確認日）","11. 配偶者名",
    '3. 氏名','4. 生年月日','5. 郵便番号','6. 電話番号１','7. 電話番号２',
    '3. 氏名','4. 生年月日',
)

# 和暦変換用
ERA_DIFF_DICT = {
    '西暦': 0,      # 将来性
    '令和': 2018,
    '平成': 1988,
    '昭和': 1925,
    '大正': 1911,
    '明治': 1868,   # 生年月日にはギリ存在する説
}

# 和暦y年m月d日 を datetimeに変換する
def parseEymd(date_string) -> datetime:
    if date_string is None :
        return None

    match = re.match(r"(..)(.+)年(\d{1,2})月(\d{1,2})日", date_string)
    if match:
        era, year, month, day = match.groups()
    else:
        print("日付形式が一致しません")
        return None
    if era not in ERA_DIFF_DICT :
        print("和暦"+era+"は未対応です")
        return None

    if year=='元' :
        year = '1'
    
    return datetime.datetime(ERA_DIFF_DICT[era]+int(year), int(month), int(day))

# xx千円、千円未満、NNNNNN千円 を変換
def parseKingaku(text) :
    if text is None :
        return 0

    text = text.replace('千円','').replace(',','')
    if text=='未満' :
        return 500
    try:
        return int(text) * 1000
    except Exception as e:
        # NNNNNN の他、わからんものは全部コレ
        return 0

# relativedelta型の値を y年m月d日の文字列に変換する
def reldelta2str(delta:relativedelta) ->str :
    if (delta.years==0) :
        if (delta.months==0) :
            result = '{}日'.format(delta.days)
        else :
            result = '{}月{}日'.format(delta.months,delta.days)
    else :
        result = '{}年{}月{}日'.format(delta.years,delta.months,delta.days)

    return result

# クレジット情報のまとめを作る
def calcSummary(cpd:CicPdfData) -> CicSummary :

    # 参考情報：算出理由まとめメモ https://x.com/rocknoir_/status/1861979220190326817

    cics = CicSummary()

    uketsukebi = parseEymd(cpd.coverDict['受付日'])

    kikanMin = 99999
    kikanMax = 0
    kikanSum = 0
    kikanCount = 0

    for rowDict in cpd.creditDataList :

        fromDate = parseEymd(rowDict['14. 契約年月日'])

        keiyakunaiyo = rowDict['13. 契約の内容']

        if keiyakunaiyo not in {'保証契約'} :
            reldelta = relativedelta(uketsukebi, fromDate)
            delta = (uketsukebi - fromDate).days
            if ( delta < kikanMin ) :
                kikanMin = delta
                cics.kikanMinStr = reldelta2str(reldelta)
            if ( kikanMax < delta ) :
                kikanMax = delta
                cics.kikanMaxStr = reldelta2str(reldelta)
            kikanCount+=1
            kikanSum+=delta
        
        kikanAvgFrom = uketsukebi + relativedelta(days=-kikanSum/kikanCount)
        cics.kikanAvgStr = reldelta2str(relativedelta(uketsukebi, kikanAvgFrom))

        ke = parseKingaku(rowDict.get('17. 契約額'))
        ky = parseKingaku(rowDict.get('18. 極度額'))

        ks = ke if ke!=0 else ky if ky!=0 else 0
        kc = parseKingaku(rowDict.get('18.(内ｷｬｯｼﾝｸﾞ枠)'))
        zs = parseKingaku(rowDict.get('25. 残債額'))
        zc = parseKingaku(rowDict.get('25.(内ｷｬｯｼﾝｸﾞ残債額)'))

        cd = cics.creditSummaryDict.get(keiyakunaiyo)

        cd.kyokudogaku += ks
        cd.kyokudogaku_c += kc
        cd.zansaigaku += zs
        cd.zansaigaku_c += zc
        cd.count_k += 1
        if zs!=0 :
            cd.count_z += 1
        if kc!=0:
            cd.count_kc += 1
        if zc!=0 :
            cd.count_zc += 1
            
        # 状況解析 （保証契約を除く）
        if keiyakunaiyo!='保証契約' :
            for i in range(0,24) :
                joukyou = rowDict.get('状況'+str(i+1))
                if joukyou is None: 
                    joukyou = ' '
                if joukyou not in cics.joukyou_kensu_dict :
                    cics.joukyou_kensu_dict[joukyou] = 0
                cics.joukyou_kensu_dict[joukyou] += 1
            
    for rowDict in cpd.applicationDataList :
        kubun = rowDict.get('10. 申込区分')
        if kubun is None : 
            continue
        if kubun not in cics.moushikomi_kensu_dict :
            cics.moushikomi_kensu_dict[kubun] = 0
        cics.moushikomi_kensu_dict[kubun] += 1

    return cics

def cicSummary2Text(cics:CicSummary) :
    summaryText = 'CICサマリ ver 0.01\n\n'

    summaryText += '【クレジット情報サマリ】\n'
    summaryText += '契約の内容  契約件数 残債アリ件数 極度(契約)額      残債額\n'

    for k,cs in cics.creditSummaryDict.items():
        if cs is not None:
            if k=='カード等' :
                summaryText += 'クレカ(S枠)   {:>4,}件       {:>4,}件  {:>11,} {:>11,}\n'.format(cs.count_k,cs.count_z,cs.kyokudogaku,cs.zansaigaku)
                summaryText += 'クレカ(C枠)   {:>4,}件       {:>4,}件  {:>11,} {:>11,}\n'.format(cs.count_kc,cs.count_zc,cs.kyokudogaku_c,cs.zansaigaku_c)
            else :
                if cs.count_k==0 :
                    continue
                padding = 6-len(k)
                k = k + '　'*padding
                summaryText +=          '{}  {:>4,}件       {:>4,}件  {:>11,} {:>11,}\n'.format(k, cs.count_k,cs.count_z,cs.kyokudogaku,cs.zansaigaku)

    summaryText += '\n'
    summaryText += '【状況件数】\n'
    for k,v in cics.joukyou_kensu_dict.items():
        comment = DEPOSIT_STATUS_DICT.get(k)
        summaryText += '{} {:>4}件 {}\n'.format(k, v, comment)

    summaryText += '\n'
    summaryText += '【新規申込件数】\n'
    for k,v in cics.moushikomi_kensu_dict.items():
        padding = 6-len(k)
        k = k + '　'*padding
        summaryText += '{} {}件\n'.format(k, v)


    summaryText += '\n'
    summaryText += '【契約期間】\n'
    summaryText += '最短 {}\n'.format(cics.kikanMinStr)
    summaryText += '平均 {}\n'.format(cics.kikanAvgStr)
    summaryText += '最長 {}\n'.format(cics.kikanMaxStr)

    return summaryText

# CSV出力(listの中にdictがあるやつ用)
# filename : ファイル名（ディレクトリはグローバル変数のoutput_pathで）
# header : 出力項目のリスト。CSVのヘッダになる
# dataList : CSVの1列がdictで、それのlist。
def outputListDictCsv(filename, header, dataList) :
    header2 = list(header)
    if not ENABLE_NUM:
        # No.のカラムを削除
        header2.remove('No.')
    if not ENABLE_PERSONAL_INFO:
        # 個人情報のカラムを削除
        for key in PERSONAL_INFO_KEY_LIST :
            if key in header2 :
                header2.remove(key)
    if not ENABLE_NULL_COL:
        # 使われているカラムを調べる
        useCol = []
        for rowDict in dataList :
            for key in rowDict.keys() :
                if key not in useCol :
                    useCol.append(key)
        # 使われていないので削除するカラムを決める
        removeCol = []
        for key in header2 :
            if key not in useCol :
                removeCol.append(key)
        # 削除
        for key in removeCol :
            header2.remove(key)

    csvString = ""
    # 1行目、CSVヘッダ
    for key in header2 :
        csvString += '"' + key + '",'
    csvString += "\n"

    # 2行目以降、CSVデータ
    for rowDict in dataList :
        for key in header2 :
            text = rowDict.get(key)
            if not text :
                text = ''
            csvString += '"' + text + '",'
        csvString += "\n"
    with open(OUTPUT_PATH + filename, "w", encoding="cp932") as output_file:
        output_file.write(csvString)

# txt出力(dict用)(表紙用)
# filename : ファイル名（ディレクトリはグローバル変数のoutput_pathで）
# dict : データ
def outputDictTxt(filename, dict) :
    csvString = ''
    for k, v in dict.items():
        csvString += k + "," + v+ "\n"
    with open(OUTPUT_PATH + filename, "w", encoding="cp932") as output_file:
        output_file.write(csvString)

# txt出力(list用)(クレジット・ガイダンス用)
# filename : ファイル名（ディレクトリはグローバル変数のoutput_pathで）
# dict : データ
def outputListTxt(filename, list) :
    csvString = ''
    for v in list:
        csvString += v+ "\n"
    with open(OUTPUT_PATH + filename, "w", encoding="cp932") as output_file:
        output_file.write(csvString)

# CICのPDFを解析する。結果は cpd に入る。
def readFromCidPdf(path, password) -> CicPdfData :
    cpd = CicPdfData()

    myPdf = MyPdf()
    try :
        myPdf.open(path, password)
    except:
        print(path + "を読み込むことができません")
        return cpd
    
    page_num = 0
    for page in myPdf.pdfPages:
        print(page_num, end="")

        pdfTextList = myPdf.parsePage(page)
        
        # ページ種別を特定（普通先頭にあるのにたまに違うのでグルグルして見つける）
        page_kind = None
        for r in pdfTextList:
            if r['bbox'][1]==535.0 and 20.0<=r['bbox'][0]<=21.0 :
                page_kind = r['text']

        if page_kind == "クレジット情報" :
            # クレジット情報の処理
            creditDataDict = {}
            for r in pdfTextList:
                ylist = CREDIT_DICT.get(r['bbox'][1])
                if ylist :
                    for xlist in ylist :
                        if xlist[0]<=r['bbox'][0]<=xlist[1] :
                            itemName = xlist[2]
                            if itemName == "No." :
                                # ページ分子整形
                                creditDataDict[itemName] = r['text'].replace('/','')
                            elif itemName == "totalNum" :
                                # ページ分母整形
                                creditDataDict[itemName] = r['text'].replace(' 件目','')
                            elif itemName == "受付番号" :
                                # 整形
                                creditDataDict[itemName] = r['text'].replace('受付番号　：','')
                            else :
                                # そのまま
                                creditDataDict[itemName] = r['text']

                elif r['bbox'][1]==68.0 :
                    # 状況 (24個の定義が面倒なので、座標計算で処理する)
                    if 54<=r['bbox'][0] :
                        # 54は開始位置, 32は1セルの幅
                        mindex = int((r['bbox'][0]-54) / 32)+1
                        creditDataDict["状況"+str(mindex)] = r['text']

            # 状況の最新年月
            if '状況年' in creditDataDict and '状況月' in creditDataDict :
                creditDataDict["状況年月"] = creditDataDict["状況年"] + '年' +creditDataDict["状況月"]

            cpd.creditDataList.append(creditDataDict)

            print(' クレジット情報 ' + creditDataDict['No.'] + '/' + creditDataDict['totalNum'])

        elif page_kind == "申込情報" :
            # 申込情報の処理
            applicationDataDictList = [{},{},{},]   # 1ページ内に3件のデータ
            for r in pdfTextList:
                y  = -1 # ページ内のn番目
                for i in range(3) :
                    if r['bbox'][1]+143*i in APPLICATION_DICT :
                        ylist = APPLICATION_DICT.get(r['bbox'][1]+143*i)
                        y = i;

                if y!=-1:
                    for xlist in ylist :
                        if xlist[0]<=r['bbox'][0]<=xlist[1] :
                            itemName = xlist[2]
                            if itemName == "No." :
                                # 項目数分子整形
                                applicationDataDictList[y][itemName] = r['text'].replace('/','')
                            elif itemName == "totalNum" :
                                # 項目数分母整形
                                applicationDataDictList[y][itemName] = r['text'].replace(' 件目','')
                            else :
                                # そのまま
                                applicationDataDictList[y][itemName] = r['text']

            # ページ内データを結果のListにまとめる
            for dict in applicationDataDictList :
                if len(dict)!=0 :
                    cpd.applicationDataList.append(dict)
            
            lastDict = cpd.applicationDataList[len(cpd.applicationDataList)-1]
            print(' 申込情報 ' + lastDict['No.'] + '/' + lastDict['totalNum'])

        elif page_kind == "利用記録" :
            # 利用記録の処理
            usageDataDictList = [[{},{},],[{},{},],[{},{},],[{},{},],]   # 1ページ内に4x2件のデータ
            for r in pdfTextList:
                y  = -1 # ページ内の縦方向n番目
                for i in range(4) :
                    if r['bbox'][1]+120*i in USAGE_DICT :
                        ylist = USAGE_DICT.get(r['bbox'][1]+120*i)
                        y = i
                        break

                if y!=-1:
                    for xlist in ylist :
                        x = -1 # ページ内の横方向n番目
                        for i in range(2) :
                            if  xlist[0] <= r['bbox'][0]-401*i <= xlist[1] :
                                x = i
                        
                        if x!=-1 :
                            itemName = xlist[2]
                            if itemName == "No." :
                                # 項目数分子整形
                                usageDataDictList[y][x][itemName] = r['text'].replace('/','')
                            elif itemName == "totalNum" :
                                # 項目数分母整形
                                usageDataDictList[y][x][itemName] = r['text'].replace(' 件目','')
                            elif itemName == "1. 登録元会社" :
                                # 整形
                                usageDataDictList[y][x][itemName] = r['text'].replace('1. 登録元会社 ： ','')
                            elif itemName == "2. 保有期限" :
                                # 整形
                                usageDataDictList[y][x][itemName] = r['text'].replace('2. 保有期限 ： ','')
                            else :
                                # そのまま
                                usageDataDictList[y][x][itemName] = r['text']

            # ページ内データを結果のListにまとめる
            for list in usageDataDictList :
                for dict in list :
                    if len(dict)!=0 :
                        cpd.usageDataList.append(dict)
            
            lastDict = cpd.usageDataList[len(cpd.usageDataList)-1]
            print(' 利用記録 ' + lastDict['No.'] + '/' + lastDict['totalNum'])
        elif page_kind == "参考情報" :
            # 参考情報の処理
            print(' 参考情報 未対応')
        elif page_kind == "クレジット・ガイダンス" :
            # クレジット・ガイダンスの処理
            for r in pdfTextList:
                if 420<r['bbox'][1]<480 and r['bbox'][0]<190 :
                    # 【指数】
                    cpd.scoreList.append(r['text'])
                elif 143<r['bbox'][1]<381 and 100<r['bbox'][0] :
                    #【算出理由】
                    cpd.scoreList.append(r['text'])
            print(' クレジット・ガイダンス')
        else:
            if page_num==0 :
                # 表紙の処理
                for r in pdfTextList:
                    key = (r['bbox'][0], r['bbox'][1])
                    itemName = COVER_DICT.get(key)
                    if itemName is not None :
                        cpd.coverDict[itemName] = r['text']
                print(' 表紙')
            elif len(pdfTextList)==1 and pdfTextList[0]['text'].startswith('Copyright'):
                # 最終ページの処理（無視）
                print(' 最終ページ')
            else :
                print(' 未知のページ(バグの予感)')

        page_num+=1

    return cpd

# CICのPDFを解析した結果をCSVなどで保存する（Excelなどで見る用）
def outputCsvs(cpd:CicPdfData) :
    # 表紙出力
    if ENABLE_PERSONAL_INFO :
        # 表紙は個人情報だけなので、個人情報を出さない場合はファイルごと出さない。
        outputDictTxt("0_表紙.txt", cpd.coverDict)

    # クレジット情報出力
    outputListDictCsv("1_クレジット情報.csv", CREDIT_HEADER, cpd.creditDataList)

    # 申込情報出力
    outputListDictCsv("2_申込情報.csv", APPLICATION_HEADER, cpd.applicationDataList)

    # 利用記録出力
    outputListDictCsv("3_利用記録.csv", USAGE_HEADER, cpd.usageDataList)

    # クレジット・ガイダンス出力
    if len(cpd.scoreList)!=0 : # ある場合だけ出力
        outputListTxt("4_クレジット・ガイダンス.txt", cpd.scoreList)

# CICのPDFを解析した結果をエクスポートする（デバッグ用）
def exportCpd(cpd:CicPdfData) :
    import pickle 

    with open(OUTPUT_PATH+'coverDict.pkl', 'wb') as f:
        pickle.dump(cpd.coverDict, f)
    with open(OUTPUT_PATH+'creditDataList.pkl', 'wb') as f:
        pickle.dump(cpd.creditDataList, f)
    with open(OUTPUT_PATH+'applicationDataList.pkl', 'wb') as f:
        pickle.dump(cpd.applicationDataList, f)
    with open(OUTPUT_PATH+'usageDataList.pkl', 'wb') as f:
        pickle.dump(cpd.usageDataList, f)
    with open(OUTPUT_PATH+'scoreList.pkl', 'wb') as f:
        pickle.dump(cpd.scoreList, f)

# ↑のインポート（デバッグ用）
def importCpd() -> CicPdfData :
    import pickle 

    cpd = CicPdfData()

    with open(OUTPUT_PATH+'coverDict.pkl', 'rb') as f:
        cpd.coverDict = pickle.load(f)
    with open(OUTPUT_PATH+'creditDataList.pkl', 'rb') as f:
        cpd.creditDataList = pickle.load(f)
    with open(OUTPUT_PATH+'applicationDataList.pkl', 'rb') as f:
        cpd.applicationDataList = pickle.load(f)
    with open(OUTPUT_PATH+'usageDataList.pkl', 'rb') as f:
        cpd.usageDataList = pickle.load(f)
    with open(OUTPUT_PATH+'scoreList.pkl', 'rb') as f:
        cpd.scoreList = pickle.load(f)
    return cpd

def main(path, password):
    
    # PDF読み込み結果の入れ物
    cpd : CicPdfData

    # CICのPDF読み込み（デバッグ時はexportしたりimportしたり）
    if DEBUG_MODE is None or DEBUG_MODE=='export':
        cpd = readFromCidPdf(path, password)

        if DEBUG_MODE == 'export' :
            exportCpd(cpd)
    
    if DEBUG_MODE == 'import' :
        cpd = importCpd()

    # CSVに出力
    outputCsvs(cpd)

    # サマリして出力
    cics = calcSummary(cpd)
    summaryText = cicSummary2Text(cics)
    with open(OUTPUT_PATH + '9_CICサマリ.txt', "w", encoding="cp932") as output_file:
        output_file.write(summaryText)

    # サマリはコンソールにも出力
    print(summaryText)

main(CIC_PDF_PATH, CIC_PDF_PASSWORD)

```

# 使い方

実行可能ファイルの配布はしません。扱う内容が個人情報過ぎるので外部送信されないことを保証したいからです。

＠dataclass と dictの順序保証 を使っているので、Python 3.7 以降です。

自分の場合は Microsoft Store から Python をインストールしました。最近のインストール操作で Python 3.12.8 が入りました。

モジュールは 先述の pdfminer の他、期間の計算で dateutil を使っています。

```
python -m pip install pdfminer
python -m pip install python-dateutil
```

ソースコードの CIC_PDF_PATH と CIC_PDF_PASSWORD を自分のPDF向けに書き換えて実行すると CSV などが出力されます。

```
CIC_PDF_PATH = "cicservice12051132.pdf"
CIC_PDF_PASSWORD = b"5256060000"
OUTPUT_PATH = "./"
```

CSVはExcelで読むことを考慮して SHIFT-JIS にしてます。

デフォルトだと列数が多すぎるので、下記項目を False にすることをお勧めします。

```
# CSV出力内容の ON/OFF
ENABLE_NUM = True           # No. を出力する/しない
ENABLE_PERSONAL_INFO = True # 個人情報を出力する/しない
ENABLE_NULL_COL = True      # 全行で空欄のカラムを出力する/しない
```

No. はあると WinMeage などの比較ツールで見るときに邪魔なので False 推奨
個人情報は見る価値ないので False 推奨
（住所の正規化されてなさぐらいを見るのは面白いかも？）
空欄カラムは無駄なので False 推奨

実行した様子
![s1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/104360/68eb345d-c2dc-3905-db51-fd90dc80fd86.png)
（住宅ローン（とそれを含む保証契約）だけマスクしています）


出力されたCSVをExcelで読むとこんな感じで見ることができます。ソートなどもできて便利ですね！
![s2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/104360/fb33dc03-37fa-f6c6-0f82-d4b376379077.png)



# 未対応

状況の $ などを数える際、使ってないから空欄の場合と契約日以前なので空欄になっているを区別していません。2年未満の契約があると空欄の件数が過大に報告されます。（たとえば半年前の契約なら18か月分の空欄は除外すべきところ、余計に18件足してしまっています）

いわゆるブラックリストなデータのサマリは弱いです。（テストデータが無いため）

# CIC信用情報の感想

極度額の合計がこんなにあるのか！ という感想です。

楽天カードを2枚持っているのですが、 NNNNNN（共通枠の場合のアレ）を使ってないので二重計上になっている気がします。
（セゾンカードと三井住友カードはNNNNNNを使ってくれています。他の会社は複数枚持ちが無いので不明）

カード会社がいう枠よりCICの極度額が大きい場合があります。この額で増枠申請すると即可決されそうな気がします。

残債額の合計もこんなにあるのか！ という感想です。投信積立をしているとタイミングによって2か月分の積立金額がでてくるのでなかなかのボリュームになります。

「保証契約」は他の契約とセットで存在します。保証を別会社にしているという向こう側の都合で出てくる情報なので、この情報はクレジットガイダンスに影響ないかも。

開示情報だけでは残債の増減は確認できないと思うのですが、クレジットガイダンスの算出理由に出てくることがあります。ちょっとナゾです。

セゾンカードを成人してすぐ作り、種類を変更しつつもなんかしらセゾンカードを持っている状態でクレヒスを積み上げてきました。

しかし、CICの登録はカードごとなので、最初に契約したカードの情報は存在せず、最長契約期間が20歳からではなくなってしまっています。

クレジットガイダンスの算出理由に出てくる「契約期間」が何を見ているのか分かりませんが、最古のカードは大事に契約し続けたほうがよさそうです。

楽天カードで不正利用があり再発行になったのですが、これは1枚のカードで契約し続けているという感じの情報になっていました。

スルガ銀行やみんなの銀行で自動貸越がセットになっていて、カードローンのような情報になってます。

ファミペイローンもカードローンですね（当たり前）

みんなの銀行については口座残高が-5万円なので、当然5万円借りてることになってます。

住宅ローンの契約終了予定日が令和34年で、永遠に来ることのない日付のような気がします。

--------

名寄せのキーとして、運転免許証の番号が強力に働いています。マイナンバーは名寄せに使ってはいけないことになっているので、マイナンバーカードが普及すると名寄せが若干弱くなってしまうのではないかと思っています。

カード契約時に、運転免許証を持ってたらそれを本人確認書類に使ってねという会社があるのはこのためです。

# 情報の取り扱いについて

CICは情報開示の利用規約 第8条（免責事項）の１で、

> 本開示請求により開示された情報を第三者に提出または開示することは、お客様の権利が損なわれるおそれがありますのでご注意ください。なお、開示された情報の第三者への提供・開示および紛失等によるトラブルにつきまして、当社は一切の責任を負いません。

と言っています。

注意してくださいねとのことで、CICは何も禁止なんかしてない、と思われます。

私も、注意してくださいねと言っておきます。

そして、本プログラムを用いたことによるトラブルにつきまして、私は一切の責任を負いません。

上記ソースコードにPDFファイル名とパスワードが書いてあって大丈夫かなと思う人もいるかもしれません。

保護すべきものはPDFファイルであって、それが無ければファイル名とパスワードは意味を持たないので大丈夫なはずです。

CICの中の人からは、「こいつやりやがったな」とバレるかもしませんが。

使い方の解説ページや動画を作ることは禁止しないどころか歓迎しますというかお願いします！

# 参考

https://qiita.com/mima_ita/items/d99afc28b6f51479f850

https://x.com/rocknoir_/status/1861979220190326817