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?

壊れた JSON も怖くない:URL 抽出と全文テキストを DataFrame にまとめる方法

Posted at

user:

指定されたパスに多数の .json ファイルがありますが、いくつかは .json ファイルとして読み込めないので、テキストファイルとして読み込みたいと思います。その際に、

"link" Entry ID" "url"

と書いてある項目は、いずれも、URLを表す項目です。

ファイルパスを指定すると、これらのファイル群をテキストファイルとして読み込んで、1列目は URL、2列目は読み込んだ全文字列、という形の pandas dataframe を返す関数を書いてください。

assistant:

了解しました。やりたいのは「指定パスのすべての .json ファイルを処理するけど、JSON としてパースできなくても、ただのテキストとして読んで、URL と本文を DataFrame にする」ですね。

ポイントは:

  • ファイルごとにまず json.load を試す。
  • 失敗したら open(...).read() で全文字列を読む。
  • URL は "link", "Entry ID", "url" のキーにある可能性がある。
  • URL が取れなかった場合は None にする。

以下の関数でいけます。

import os
import glob
import json
import pandas as pd

def load_jsons_as_text_with_url(path: str) -> pd.DataFrame:
    """
    指定パスの .json ファイル群を読み込み、
    各ファイルの URL と全文字列を pandas DataFrame に格納して返す。

    DataFrameの列:
        - 'url': URL (link, Entry ID, url のいずれかに格納されている)
        - 'text': ファイルの全文字列
    """
    rows = []
    json_files = glob.glob(os.path.join(path, "*.json"))
    
    for json_file in json_files:
        url = None
        text_content = None
        
        try:
            # JSON として読んでみる
            with open(json_file, "r", encoding="utf-8") as f:
                data = json.load(f)
                text_content = json.dumps(data, ensure_ascii=False)
                
                # URL の候補キーを探す
                for key in ["link", "Entry ID", "url"]:
                    if isinstance(data, dict) and key in data:
                        url = data[key]
                        break
        except Exception:
            # JSON 読み込み失敗時はテキストとして読む
            with open(json_file, "r", encoding="utf-8", errors="ignore") as f:
                text_content = f.read()
            
            # 簡易的に URL を抽出("link": "..." などから拾う)
            for key in ["link", "Entry ID", "url"]:
                marker = f'"{key}"'
                idx = text_content.find(marker)
                if idx != -1:
                    # その位置から引用符で囲まれた URL を拾う
                    start = text_content.find('"', idx + len(marker))
                    end = text_content.find('"', start + 1)
                    if start != -1 and end != -1:
                        url = text_content[start+1:end]
                        break
        
        rows.append({"url": url, "text": text_content})
    
    return pd.DataFrame(rows)

使い方はこんな感じです:

df = load_jsons_as_text_with_url("/path/to/jsons/")
print(df.head())

これで、1列目にURL、2列目に全文字列が入ります。

ご希望に合わせて「URL が見つからない場合の扱い」を調整できますけど、None にしておくのが無難だと思います。

質問ですが:URL が見つからなかったファイルは None のままで大丈夫ですか?それともファイル名を URL 代わりに埋めますか?

user:

ありがとうございます。でも

df['url'].unique() 

した結果が

array([None], dtype=object) 

でした。URLが全く拾えていません。

assistant:

なるほど!つまり json.load でもテキスト解析でも url がちゃんと拾えていないんですね。
ここで注意したいのは、実際の JSON やテキストの構造です。

たとえば JSON の中がこんな形なら:

{
  "link": "https://example.com/page1",
  "title": "サンプル"
}

これは問題なく取れるはずですが、
もしリスト形式だったり、ネストされていたりすると、単純な data[key] では見つからないのです。

なので「辞書の入れ子やリストの中まで総当たりで探索する」関数を用意した方がいいです。

修正版を示します👇

import os
import glob
import json
import pandas as pd

def extract_url_from_data(data, keys=("link", "Entry ID", "url")):
    """
    JSONデータの中から候補キーを再帰的に探してURLを返す。
    """
    if isinstance(data, dict):
        for k, v in data.items():
            if k in keys and isinstance(v, str):
                return v
            else:
                found = extract_url_from_data(v, keys)
                if found:
                    return found
    elif isinstance(data, list):
        for item in data:
            found = extract_url_from_data(item, keys)
            if found:
                return found
    return None

def load_jsons_as_text_with_url(path: str) -> pd.DataFrame:
    rows = []
    json_files = glob.glob(os.path.join(path, "*.json"))
    
    for json_file in json_files:
        url = None
        text_content = None
        
        try:
            with open(json_file, "r", encoding="utf-8") as f:
                data = json.load(f)
                text_content = json.dumps(data, ensure_ascii=False)
                url = extract_url_from_data(data)
        except Exception:
            # JSONとして読めなければテキストとして
            with open(json_file, "r", encoding="utf-8", errors="ignore") as f:
                text_content = f.read()
            
            # 正規表現でURLを抜き出す(httpから始まるもの)
            import re
            match = re.search(r'https?://[^\s"\']+', text_content)
            if match:
                url = match.group(0)
        
        rows.append({"url": url, "text": text_content})
    
    return pd.DataFrame(rows)

このバージョンでは:

  • JSON として読めたら、中を再帰的に探索して "link", "Entry ID", "url" を探す。
  • JSON として読めなかった場合は 正規表現で http(s)://... を拾う

これなら URL をかなりの確率で拾えるはずです。

確認したいのですが、あなたの .json ファイルの中身は「1階層の dict」形式が多いですか?それとも「リストやネストされた dict」が多いですか?

user:

どっちも多いですね。いくらかURLを拾えるようになりましたが、もっと拾えるようにできませんか?

assistant:

なるほど、了解です。今の方法でも URL を拾える確率は上がりましたが、まだ取りこぼしがあるのですね。原因は大きく分けて2つです:

  1. JSONとして読めないテキストに URL が書かれている場合、今は単純に "http://""https://" から始まる文字列しか拾っていないこと。実際には "link": "..." のような書き方や、改行や全角スペースで区切られている場合もあります。
  2. ネスト構造やリストの深い中にある URL は、今の再帰探索でも取りこぼす場合があること。特に文字列として JSON 内に埋め込まれている場合は拾えません。

もっと拾うためのアプローチ:

  • 正規表現を強化して URL らしきものを幅広く拾う

    • http:///https:// 以外に www. から始まるものも対象にする
    • "link"\s*:\s*".*?" など、"キー" : "値" の形式も正規表現で拾う
  • JSON 内文字列まで解析して URL を抜く

    • JSON の値として文字列が入っている場合、その中に URL が埋まっていることがあるので、文字列も正規表現で検索する

改良版を作ってみました。これなら、JSON が正しく読める場合も、テキストとして読んだ場合も URL の取得率がかなり上がります:

import os
import glob
import json
import pandas as pd
import re

def extract_urls_from_text(text, keys=("link", "Entry ID", "url")):
    """
    テキストから URL を幅広く抽出
    """
    urls = set()
    # "キー":"URL" の形式をまず探す
    for key in keys:
        pattern = re.compile(rf'"{key}"\s*:\s*"([^"]+)"', re.IGNORECASE)
        urls.update(pattern.findall(text))
    
    # http(s):// 形式の URL も拾う
    urls.update(re.findall(r'https?://[^\s"\'<>]+', text))
    
    # www. 形式も拾う
    urls.update(re.findall(r'www\.[^\s"\'<>]+', text))
    
    return list(urls)

def extract_url_from_data(data, keys=("link", "Entry ID", "url")):
    """
    JSONデータの中から再帰的に URL を探索
    """
    if isinstance(data, dict):
        for k, v in data.items():
            if k in keys and isinstance(v, str):
                return v
            else:
                found = extract_url_from_data(v, keys)
                if found:
                    return found
    elif isinstance(data, list):
        for item in data:
            found = extract_url_from_data(item, keys)
            if found:
                return found
    elif isinstance(data, str):
        urls = extract_urls_from_text(data, keys)
        if urls:
            return urls[0]
    return None

def load_jsons_as_text_with_url(path: str) -> pd.DataFrame:
    rows = []
    json_files = glob.glob(os.path.join(path, "*.json"))
    
    for json_file in json_files:
        url = None
        text_content = None
        
        try:
            with open(json_file, "r", encoding="utf-8") as f:
                data = json.load(f)
                text_content = json.dumps(data, ensure_ascii=False)
                url = extract_url_from_data(data)
                
                # URL が見つからなければ、文字列中も探索
                if not url:
                    url_list = extract_urls_from_text(text_content)
                    if url_list:
                        url = url_list[0]
        except Exception:
            # JSON 読み込み失敗時
            with open(json_file, "r", encoding="utf-8", errors="ignore") as f:
                text_content = f.read()
            url_list = extract_urls_from_text(text_content)
            if url_list:
                url = url_list[0]
        
        rows.append({"url": url, "text": text_content})
    
    return pd.DataFrame(rows)

ポイント:

  • JSON 内の文字列まで再帰的に検索
  • 正規表現で "link": "..."http(s)://www. 形式の URL を幅広く拾う
  • URL が複数あってもとりあえず最初の1つを url に格納

この方法なら、かなりのファイルで URL が取得できるはずです。

希望をもう少し言うと、複数 URL をすべて拾って DataFrame に複数行として展開することも可能です。
その方が後で分析しやすいですが、やりますか?

user:

ありがとうございます。だいぶいい線いってますね。日本語の文字はURLに含まれません、という制限を加えてもらっていいですか。

assistant:

なるほど、URL に日本語文字(全角文字など)が入っていると拾わないようにしたいわけですね。
それなら正規表現を少し変えて、「ASCII 文字のみの URL」に限定すればOKです。

具体的には、先ほどの extract_urls_from_text を次のように修正します:

def extract_urls_from_text(text, keys=("link", "Entry ID", "url")):
    """
    テキストから URL を幅広く抽出
    - URL に日本語文字が含まれていたら除外
    """
    urls = set()
    
    # "キー":"URL" の形式をまず探す
    for key in keys:
        pattern = re.compile(rf'"{key}"\s*:\s*"([^"]+)"', re.IGNORECASE)
        for u in pattern.findall(text):
            if all(ord(c) < 128 for c in u):  # ASCII のみ
                urls.add(u)
    
    # http(s):// 形式の URL
    for u in re.findall(r'https?://[^\s"\'<>]+', text):
        if all(ord(c) < 128 for c in u):
            urls.add(u)
    
    # www. 形式も同様
    for u in re.findall(r'www\.[^\s"\'<>]+', text):
        if all(ord(c) < 128 for c in u):
            urls.add(u)
    
    return list(urls)

ポイントはこの部分です:

if all(ord(c) < 128 for c in u)
  • URL の文字列 u を 1 文字ずつチェック
  • ASCII(半角英数字・記号)だけなら採用
  • 日本語や全角文字が入っていればスキップ

この修正を先ほどの load_jsons_as_text_with_url 関数に組み込めば、URL 列に日本語入りの URL は出てこなくなります。

希望なら、このバージョンを 最初から最後まで統合した完成版 を作ってお渡しすることもできます。作りますか?

user:

ありがとうございます。これで解決です。今夜はこの辺にしときましょう。ここまでの内容をブログにまとめたいので、タイトル案をいくつか考えていただけますか?

assistant:

うん、いい締めくくりですね。今回のテーマは「壊れたりフォーマットがバラバラな JSON をどうにか読み込んで、URL とテキストを pandas で整理する」でしたから、その軸でいくつかタイトルを考えてみました。

  1. 「壊れた JSON も怖くない:URL 抽出と全文テキストを DataFrame にまとめる方法」
  2. 「pandas で JSON をテキストとして扱う ― URL 抽出の実践」
  3. 「JSON が読めなくても大丈夫!URL と本文をきれいに整理する Python 関数」
  4. 「混在する JSON ファイル群から URL とテキストを抽出する pandas 活用術」
  5. 「JSON として読めないファイルも扱う:URL 抽出 + DataFrame 化の実装」

この中なら、検索性や実用感を考えると「壊れた JSON も怖くない…」がキャッチーですが、技術ブログとしては「混在する JSON ファイル群から…」が一番まじめで引きがあると思います。

どちらのトーンでまとめたいですか?キャッチー寄りか、実務寄りか。

user:

「壊れた JSON も怖くない:URL 抽出と全文テキストを DataFrame にまとめる方法」でいきましょう。このタイトルに沿って、ここまでの内容を詳細に解説してください。

assistant:

壊れた JSON も怖くない:URL 抽出と全文テキストを DataFrame にまとめる方法

目的:指定したフォルダ内の .json ファイル群を読み込み、

  • 1列目:URL("link", "Entry ID", "url" のいずれか、または本文中に現れる URL)
  • 2列目:そのファイルから読み取った全文字列(JSON として読み込めた場合は json.dumps の結果、読み込めなければテキストそのもの)

という形の pandas.DataFrame を返す関数を作ります。
追加要件として「URL に日本語文字(全角文字など)が含まれるものは除外する(ASCII のみ許容)」という制約を入れます。


アプローチ(ざっくり)

  1. 各ファイルをまず json.load() で読み込もうとする。

    • 成功したら:内部を再帰的に探索して link / Entry ID / url キーを探す。
    • 見つからなければ、JSON を文字列化したもの(json.dumps)に対して正規表現で URL を探す。
  2. json.load() が失敗(壊れたファイルや JSONL など)したら、テキストとして全文を読み、正規表現で URL を探す。

  3. URL 抽出時は 日本語(非 ASCII)を含む候補を除外。また末尾の句読点などのゴミを取り除く。

  4. 最終的に pandas.DataFrame を作成し、列順は ['url', 'text'] とする(ご指定に合わせる)。


実装(完成版コード)

以下がそのまま使える関数一式です。path に対象ディレクトリを渡すと DataFrame が返ります。

import os
import glob
import json
import re
from typing import List, Optional, Tuple
import pandas as pd

def _is_ascii(s: str) -> bool:
    """文字列が ASCII のみかを判定(True: ASCII のみ)。"""
    try:
        s.encode('ascii')
        return True
    except UnicodeEncodeError:
        return False

def _normalize_candidate_url(u: str, ascii_only: bool = True) -> Optional[str]:
    """
    候補 URL をトリムし、末尾の句読点を取り、ASCII 制約を満たすかチェックして返す。
    不正なら None を返す。
    """
    if not isinstance(u, str) or not u:
        return None
    u = u.strip().strip('\'"')            # 周囲の引用符を除去
    u = u.rstrip('.,;:!?)\]}\''"')        # 末尾の余分な句読点を除去
    if u.startswith('<') and u.endswith('>'):
        u = u[1:-1].strip()
    # 最低限の妥当性(スキームか www. かドメインっぽさ)
    if not (u.startswith('http://') or u.startswith('https://') or u.startswith('www.') or '.' in u):
        return None
    if ascii_only and (not _is_ascii(u)):
        return None
    return u

def extract_urls_from_text(text: str,
                           keys: Tuple[str, ...] = ("link", "Entry ID", "url"),
                           ascii_only: bool = True) -> List[str]:
    """
    テキストから URL 候補を幅広く抽出してリストで返す(重複除去済み、順序保持)。
    - keys に対する "key":"value" 形式の抽出を優先
    - 次に http(s)://...、www.... を抽出
    - 抽出した文字列は _normalize_candidate_url でフィルタする
    """
    if not text:
        return []
    found = []
    # "key":"value"(シングル/ダブル両対応)
    for key in keys:
        pattern = re.compile(rf'["\']{re.escape(key)}["\']\s*:\s*["\']([^"\']+)["\']', re.IGNORECASE)
        for m in pattern.findall(text):
            u = _normalize_candidate_url(m, ascii_only=ascii_only)
            if u and u not in found:
                found.append(u)
    # http(s):// 形式
    for m in re.findall(r'https?://[^\s"\'<>]+', text):
        u = _normalize_candidate_url(m, ascii_only=ascii_only)
        if u and u not in found:
            found.append(u)
    # www. 形式(先頭/空白の後に現れることを想定)
    for m in re.findall(r'(?:(?<=\s)|^)(www\.[^\s"\'<>]+)', text):
        u = _normalize_candidate_url(m, ascii_only=ascii_only)
        if u and u not in found:
            found.append(u)
    return found

def extract_url_from_data(data, keys: Tuple[str, ...] = ("link", "Entry ID", "url"),
                          ascii_only: bool = True) -> Optional[str]:
    """
    JSON パース済みオブジェクト(dict/list/str)を再帰的に検索して、最初に見つかった URL を返す。
    文字列値の中に潜む URL も正規表現で探す。
    """
    if isinstance(data, dict):
        for k, v in data.items():
            # キー一致で文字列値ならそれを正規化して返す
            if k in keys and isinstance(v, str):
                u = _normalize_candidate_url(v, ascii_only=ascii_only)
                if u:
                    return u
            # 再帰探索
            found = extract_url_from_data(v, keys=keys, ascii_only=ascii_only)
            if found:
                return found
    elif isinstance(data, list):
        for item in data:
            found = extract_url_from_data(item, keys=keys, ascii_only=ascii_only)
            if found:
                return found
    elif isinstance(data, str):
        urls = extract_urls_from_text(data, keys=keys, ascii_only=ascii_only)
        if urls:
            return urls[0]
    return None

def load_jsons_as_text_with_url(path: str,
                                keys: Tuple[str, ...] = ("link", "Entry ID", "url"),
                                ascii_only: bool = True,
                                recursive: bool = False,
                                filename_fallback: bool = False) -> pd.DataFrame:
    """
    指定パスの .json ファイル群を読み込み、URL と全文テキストを持つ DataFrame を返す。

    Parameters
    ----------
    path : str
        対象ディレクトリ(末尾に / を含めても可)。ファイル名にワイルドカードは不要。
    keys : tuple
        JSON 内で URL を示す可能性のあるキーのタプル。
    ascii_only : bool
        True の場合、日本語(非 ASCII)を含む URL 候補を除外する。
    recursive : bool
        True にするとサブディレクトリも再帰的に検索する(glob の ** を使用)。
    filename_fallback : bool
        URL が検出できなかった場合にファイル名を代わりに埋めるかどうか。

    Returns
    -------
    pandas.DataFrame
        列順は ['url', 'text']
    """
    pattern = '**/*.json' if recursive else '*.json'
    search_path = os.path.join(path, pattern)
    json_files = glob.glob(search_path, recursive=recursive)

    rows = []
    for jf in json_files:
        url = None
        text_content = None

        # まず JSON として読み込む試行
        try:
            with open(jf, 'r', encoding='utf-8') as f:
                data = json.load(f)
            # テキスト化(可読性のため ensure_ascii=False)
            text_content = json.dumps(data, ensure_ascii=False)
            # JSON 内を再帰探索して URL を探す
            url = extract_url_from_data(data, keys=keys, ascii_only=ascii_only)
            # 見つからなければ JSON を文字列として再度正規表現検出
            if not url:
                url_list = extract_urls_from_text(text_content, keys=keys, ascii_only=ascii_only)
                if url_list:
                    url = url_list[0]
        except Exception:
            # JSON パース失敗時はテキストとして読む(エンコーディング問題は ignore)
            with open(jf, 'r', encoding='utf-8', errors='ignore') as f:
                text_content = f.read()
            url_list = extract_urls_from_text(text_content, keys=keys, ascii_only=ascii_only)
            if url_list:
                url = url_list[0]

        if not url and filename_fallback:
            url = os.path.basename(jf)

        rows.append({'url': url, 'text': text_content})

    df = pd.DataFrame(rows, columns=['url', 'text'])
    return df

使い方例

# ディレクトリ直下の .json を処理
df = load_jsons_as_text_with_url('/path/to/jsons')

# サブディレクトリも含めて再帰的に処理し、URLが見つからない場合はファイル名を代替値にする
df = load_jsons_as_text_with_url('/path/to/jsons', recursive=True, filename_fallback=True)

# 確認
print(df.shape)
print(df.head())
print(df['url'].nunique())

この関数は要求通り、返される DataFrame の1列目が URL(None が許容されます)、2列目がファイルの全文字列です。


実装上のポイント解説(なぜこうしたか)

  • まず json.load() を試す理由

    • 正しい JSON として読めた場合、その構造を使って確実に link 等のキーを取り出せる。文字列中にエスケープされた URL が埋まっている場合も検出しやすい。
  • 壊れたファイルはテキスト読みでカバー

    • 実務では部分的に壊れた JSON や JSONL、CSV 化されて拡張子だけ .json になっているケースがあり得る。errors='ignore' で読み切って正規表現で抽出するのが現実的。
  • 正規表現と正規化

    • https?://...www. の検出は基本だが、後段で末尾の句読点や山括弧 <...> を取り除くことで誤検出を減らす。
  • ASCII(非日本語)制約

    • 「日本語文字を含む URL を除外する」という要望は _is_ascii チェックで実現。Punycode(xn--)や%エンコードは ASCII の範囲なので問題なく残る。
  • 優先順位

    • JSON のキー("link": "...")をまず探し、それでダメなら本文中の URL を探す。確度の高いデータを優先的に採用するため。

追加でできる改善・拡張案(すぐ使えるヒント)

  • すべての URL を列挙して explode() する

    • 今の実装は「最初に見つかった URL」を url に入れます。すべての URL を出したい場合は extract_urls_from_text を用いてリストを作成し、df.explode('all_urls') する形で long 形式にできます。
  • エンコーディング検出

    • 必要なら chardetcharset-normalizer を使って読み込み時のエンコーディングを推定し、 errors='ignore' を減らすとデータ品質が向上します。
  • 並列処理

    • ファイル数が非常に多ければ concurrent.futures.ThreadPoolExecutor で並列化して高速化できます(I/O バウンドなのでスレッド有利)。
  • 正規化(正規化ライブラリ)

    • urllib.parse を使って URL を正規化(スキームの付与、末尾スラッシュ除去など)すると後処理が楽になります。
  • ログ/進捗表示

    • tqdm で進捗バーを出すと処理中の安心感が上がります。

注意点(現場でよくある落とし穴)

  • 「URL とみなしたい表現」が非常に多様なため、完璧な抽出は困難。特に HTML の断片やエスケープが多い場合は正規表現で取りこぼすことがあります。
  • 日本語ドメイン(国際化ドメイン名)を素の全角で保存しているファイルは今回の ascii_only=True の設定で除外されます。必要なら ascii_only=False に切り替え、 punycode 変換等で扱ってください。
  • ファイルが巨大(数十 MB~)だとメモリや速度の点で工夫が必要(ストリーミング処理や chunk 読み)。

まとめ

  • 壊れた JSON もテキスト読みで拾えばかなりの精度で URL を回収できます。
  • 本稿の関数は「JSON として読み込める場合は構造を活用」「読み込めない場合は本文を正規表現で検索」を組み合わせ、かつ日本語を含む URL を除外する仕様になっています。
  • 必要に応じて「全 URL を列挙する」「エンコーディングをきちんと検出する」「並列化する」などの拡張が可能です。

お疲れさまでした — 今夜はここまでで大丈夫ですね。必要なら、上のコードをあなたの環境で動かしやすい形に小分けしてお渡しします(サンプルファイルを想定したテストコードなど)。

user:

ありがとうございます。これで大丈夫です。今夜もお疲れ様でした!

assistant:

お疲れさまでした!無事に仕上がってよかったです。
壊れた JSON を相手にするのは骨が折れる作業だけど、その分データ整形の腕もぐんと鍛えられますね。

また明日以降も一緒に遊びましょう。
ゆっくり休んでくださいね 🌙

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?