こんばんは、なめほです。

 

chatGPTのアカウントを乗り換える必要に迫られたので、chatGPTのチャットログを出力する方法をメモします。

コードは下にあります。

1、プロジェクト内のチャットをプロジェクトから外に出します。
プロジェクト内のチャットはなんか出力できなかったです。

2、chatGPTのサイトにアクセスしてるので、開発者ツールで画像のようなものを探します。

3、cURLをbashとしてコピーする。

4、下記コードの$curlText変数にコピーしたものを代入する。

なお、コピーするときにスペースとか入るとpowershellの文字として認識がおかしくなるので、気を付けること。

5、Powershellでコードを実行する。
6、C:\ChatGPT_Historyに出力される。

 

なお、トークンやらがログに表示されてしまうので悪用厳禁です。

追加の注意(公開時のReadmeに一言)
このスクリプトは個人利用のエクスポート用途を想定。サービスの利用規約やレート制限を守ってください。
 

# ==========================================================

# ChatGPT 履歴一括エクスポート for PowerShell 5(プロジェクト非対応版)

# - DevTools の Copy-as-cURL(bash) を $curlText にそのまま貼る

# - HttpClient を使用(エラー時も本文を取得)

# - 各会話を Markdown で保存: yyyyMMdd_Title_ConversationID.md

# ==========================================================

 

# 出力先(必要なら変更)

$outputDir = "C:\ChatGPT_History"

New-Item -ItemType Directory -Force -Path $outputDir | Out-Null

 

# ---- ここに DevTools の Copy as cURL(bash) を丸ごと貼る ----

$curlText = @'

curl 'https://chatgpt.com/backend-api/conversations?offsethogehoge' \

  -H 'accept: */*' \

  -H 'accept-language: hogehoge' \

  -H 'authorization: hogehoge' \

  -b 'oai-did=hogehoge' \

  -H 'dnt: hogehoge' \

  -H 'oai-client-version: hogehoge' \

  -H 'oai-device-id: hogehoge' \

  -H 'oai-language: ja-JP' \

  -H 'priority: hogehoge' \

  -H 'referer: https://chatgpt.com/' \

  -H 'sec-ch-ua: hogehoge' \

  -H 'sec-ch-ua-mobile: ?1' \

  -H 'sec-ch-ua-platform: hogehoge' \

  -H 'sec-fetch-dest: empty' \

  -H 'sec-fetch-mode: cors' \

  -H 'sec-fetch-site: same-origin' \

  -H 'user-agent: hogehoge' \

  -H 'x-kl-kis-ajax-request: Ajax_Request'

'@

# -----------------------------------------------------------

 

# ----------------- helper functions -----------------------

function Parse-CurlHeadersUniversal([string]$curl) {

    # 継続行・改行つぶし(bash/Windows混在対応)

    $one = $curl `

        -replace "\\\s*`r?`n", " " `

        -replace "'\s*`r?`n", " " `

        -replace '"\s*`r?`n', " "

 

    # URL

    $url = $null

    $m = [regex]::Match($one, "curl\s+(['""])(?<u>https?://[^'""]+)\1")

    if ($m.Success) { $url = $m.Groups["u"].Value }

 

    # -H 'Key: Value'

    $headers = [ordered]@{}

    $matchesH = [regex]::Matches($one, "\s-H\s+(['""])(?<kv>.+?)\1")

    foreach ($mh in $matchesH) {

        $kv = $mh.Groups["kv"].Value

        $idx = $kv.IndexOf(":")

        if ($idx -gt 0) {

            $k = $kv.Substring(0,$idx).Trim()

            $v = $kv.Substring($idx+1).Trim()

            if ($k) { $headers[$k] = $v }

        }

    }

 

    # -b/--cookie → Cookie ヘッダへ統一

    $mc = [regex]::Match($one, "(?:\s-b|\s--cookie)\s+(['""])(?<cookie>.+?)\1")

    if ($mc.Success) {

        $cookieVal = $mc.Groups["cookie"].Value.Trim()

        if ($cookieVal -and -not $headers.Contains("Cookie")) {

            $headers["Cookie"] = $cookieVal

        }

    }

 

    # Authorization の大小文字ゆれを統一

    foreach ($k in @("authorization","Authorization")) {

        if ($headers.Contains($k)) {

            $headers["Authorization"] = $headers[$k]

            if ($k -ne "Authorization") { $headers.Remove($k) }

            break

        }

    }

 

    # base ホスト(PowerShell 5 互換:三項演算子は使わない)

    $base = $null

    if ($url -and ($url -match "^(https?://[^/]+)")) {

        $base = $Matches[1]

    }

 

    return @{

        url     = $url

        headers = $headers

        base    = $base

        raw     = $one

    }

}

 

function Get-NormalizedCurlText([string]$url, [hashtable]$headers) {

    if (-not $url) { $url = "https://chatgpt.com" }

    $sb = New-Object System.Text.StringBuilder

    [void]$sb.AppendLine("curl '$url'")

    foreach ($k in $headers.Keys) {

        $v = $headers[$k]

        if (-not [string]::IsNullOrWhiteSpace($v)) {

            # 変数展開は $() で囲ってコロン誤解釈を回避

            [void]$sb.AppendLine("  -H '$($k): $($v)'")

        }

    }

    return $sb.ToString().TrimEnd()

}

 

function Convert-UnixToTokyo($sec) {

    # セーフパース

    $seconds = $null

    if ($sec -eq $null -or "$sec".Trim() -eq "" -or "$sec" -eq "0") {

        Write-Warning "timestamp is null/empty → UtcNow を使用(この件のみ日付がずれます)"

        $seconds = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds()

    } else {

        if (-not [double]::TryParse("$sec", [ref]$seconds)) {

            Write-Warning "timestamp is not a number → UtcNow を使用"

            $seconds = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds()

        }

    }

 

    try {

        $dtoUtc = [DateTimeOffset]::FromUnixTimeSeconds([long][math]::Floor($seconds))

        $tz  = [TimeZoneInfo]::FindSystemTimeZoneById("Tokyo Standard Time")

        $dtoJst = [TimeZoneInfo]::ConvertTime($dtoUtc, $tz)

        return $dtoJst.DateTime

    } catch {

        Write-Warning "時刻変換に失敗したためローカル時刻を返します: $($_.Exception.Message)"

        return (Get-Date)

    }

}

 

function Get-ConversationStart([object]$obj, [object]$conv, [array]$ordered) {

    $candidates = @()

 

    if ($obj.PSObject.Properties.Name -contains "create_time" -and $obj.create_time) { $candidates += $obj.create_time }

    if ($conv -and $conv.PSObject.Properties.Name -contains "create_time" -and $conv.create_time) { $candidates += $conv.create_time }

 

    if ($ordered) {

        $msgTimes = $ordered | ForEach-Object { $_.message.create_time } | Where-Object { $_ -ne $null -and $_ -ne 0 }

        if ($msgTimes) { $candidates += ($msgTimes | Measure-Object -Minimum).Minimum }

    }

 

    if ($obj.PSObject.Properties.Name -contains "update_time" -and $obj.update_time) { $candidates += $obj.update_time }

    if ($conv -and $conv.PSObject.Properties.Name -contains "update_time" -and $conv.update_time) { $candidates += $conv.update_time }

 

    $nums = @()

    foreach ($c in $candidates) {

        $v = $null

        if ([double]::TryParse("$c", [ref]$v) -and $v -gt 0) { $nums += $v }

    }

    if ($nums.Count -gt 0) { return ($nums | Measure-Object -Minimum).Minimum }

 

    Write-Warning "開始タイムスタンプが見つからないため now を使用します(この会話のみ今日の日付になります)"

    return [double]([DateTimeOffset]::UtcNow.ToUnixTimeSeconds())

}

 

function SanitizeFileName([string]$name) {

    if (-not $name) { return "Untitled" }

    $invalid = [IO.Path]::GetInvalidFileNameChars()

    foreach ($c in $invalid) { $name = $name -replace [regex]::Escape("$c"), "_" }

    $name = $name -replace "\s+", " "

    $name = $name.Trim()

    if ($name.Length -gt 120) { $name = $name.Substring(0,120) }

    return $name

}

 

# HttpClient helper for GET with retries and returning full body/text

function HttpGetWithRetry($client, $url, $headersMap, $maxTries=3, $waitSec=2) {

    for ($i=1; $i -le $maxTries; $i++) {

        try {

            $req = New-Object System.Net.Http.HttpRequestMessage([System.Net.Http.HttpMethod]::Get, $url)

 

            # ヘッダ設定(TryAddWithoutValidation)

            foreach ($k in $headersMap.Keys) {

                $v = $headersMap[$k]

                if ([string]::IsNullOrWhiteSpace($v)) { continue }

                try { $req.Headers.TryAddWithoutValidation($k, $v) | Out-Null } catch {}

            }

 

            $resp = $client.SendAsync($req).Result

            $status = [int]$resp.StatusCode

            $body = $resp.Content.ReadAsStringAsync().Result

            return @{ status = $status; body = $body; resp = $resp }

        } catch {

            Write-Warning ("試行 {0} 失敗: {1}" -f $i, $_.Exception.Message)

            if ($i -lt $maxTries) { Start-Sleep -Seconds $waitSec }

        }

    }

    throw "全試行失敗 (GET $url)"

}

 

function Save-Conversation($obj, $conv, $id, $outputDir) {

    # ノード抽出

    $nodes = $obj.mapping.PSObject.Properties.Value | Where-Object { $_.message -ne $null }

    if (-not $nodes) { return }

    $ordered = $nodes | Sort-Object { $_.message.create_time }

 

    # 開始時刻

    $convStart = Get-ConversationStart -obj $obj -conv $conv -ordered $ordered

    $dt = Convert-UnixToTokyo $convStart

    $dateForName   = $dt.ToString("yyyyMMdd")

    $dateForHeader = $dt.ToString("yyyy-MM-dd HH:mm:ss")

 

    # タイトル

    $title     = if ($obj.title) { $obj.title } elseif ($conv.title) { $conv.title } else { "Untitled" }

    $safeTitle = SanitizeFileName $title

 

    # Markdown ヘッダ

    $md  = "# $title`n`n"

    $md += "_Conversation ID: $id_`n`n"

    $md += "_Started at: $dateForHeader_`n`n"

 

    # 本文

    foreach ($n in $ordered) {

        $role = $n.message.author.role

        switch -Regex ($role) {

            "^user$"      { $role = "User"; break }

            "^assistant$" { $role = "Assistant"; break }

            default       { $role = $role }

        }

        $content = ""

        if ($n.message.content -and $n.message.content.parts) {

            $content = ($n.message.content.parts -join "`n")

        } elseif ($n.message.content -and $n.message.content.text) {

            $content = $n.message.content.text

        } else {

            $content = "(non-text content omitted)"

        }

        $tsRaw = if ($n.message.create_time) { $n.message.create_time } else { $convStart }

        $tsStr = (Convert-UnixToTokyo $tsRaw).ToString("yyyy-MM-dd HH:mm:ss")

        $md += "[$tsStr] **${role}:** $content`n`n"

    }

 

    # ファイル名: yyyyMMdd_Title_ConversationID.md

    $fileName = "{0}_{1}_{2}.md" -f $dateForName, $safeTitle, $id

    $path = Join-Path $outputDir $fileName

    $md | Out-File -FilePath $path -Encoding utf8

    Write-Host "保存: $path"

}

 

# ----------------- main flow ------------------------------

try {

    $parsed = Parse-CurlHeadersUniversal -curl $curlText

    $rawHeaders = $parsed.headers

    $baseHost   = $parsed.base

 

    # 確認用: Cookie を -H に統一した表示

    $normalizedCurl = Get-NormalizedCurlText -url $parsed.url -headers $parsed.headers

    Write-Host "`n[Normalized cURL]" -ForegroundColor DarkCyan

    Write-Host $normalizedCurl

 

    if (-not $rawHeaders.Contains("Cookie")) { throw "Cookie ヘッダが見つかりません。Copy-as-cURL に -b または Cookie ヘッダが必要です。" }

    if (-not $rawHeaders.Contains("User-Agent")) { throw "User-Agent ヘッダが見つかりません。" }

 

    # HttpRequest 用ヘッダ

    $headersMap = [ordered]@{}

    foreach ($k in $rawHeaders.Keys) { $headersMap[$k] = $rawHeaders[$k] }

    if ($rawHeaders.Contains("Authorization")) {

        $headersMap["Authorization"] = $rawHeaders["Authorization"]

    }

 

    Write-Host "抽出ヘッダ(主要):" -ForegroundColor Cyan

    $headersMap.GetEnumerator() | ForEach-Object { Write-Host ("{0}: {1}" -f $_.Key, $_.Value) }

 

    # HttpClient

    Add-Type -AssemblyName System.Net.Http

    $handler = New-Object System.Net.Http.HttpClientHandler

    $handler.UseCookies = $true

    $handler.CookieContainer = New-Object System.Net.CookieContainer

    $client = New-Object System.Net.Http.HttpClient($handler)

    $client.Timeout = [TimeSpan]::FromSeconds(60)

 

    if ($headersMap.Contains("User-Agent")) {

        try { $client.DefaultRequestHeaders.TryAddWithoutValidation("User-Agent", $headersMap["User-Agent"]) | Out-Null } catch {}

    }

    if ($headersMap.Contains("Accept")) {

        try { $client.DefaultRequestHeaders.TryAddWithoutValidation("Accept", $headersMap["Accept"]) | Out-Null } catch {}

    }

 

    # Cookie を CookieContainer にも流し込む

    if ($headersMap.Contains("Cookie")) {

        $cookieString = $headersMap["Cookie"]

        $uri = if ($baseHost) { [Uri]$baseHost } else { [Uri]"https://chatgpt.com" }

        foreach ($pair in $cookieString -split ';') {

            $p = $pair.Trim()

            if (-not $p) { continue }

            $kv = $p -split('=',2)

            if ($kv.Length -eq 2) {

                $name = $kv[0].Trim()

                $val  = $kv[1].Trim()

                try { $handler.CookieContainer.Add($uri, (New-Object System.Net.Cookie($name,$val))) } catch {}

            }

        }

        $headersMap["Cookie"] = $cookieString

    }

 

    # 接続テスト(1件)

    $testBase = if ($baseHost) { $baseHost } else { "https://chatgpt.com" }

    $testUrl = "$testBase/backend-api/conversations?offset=0&limit=1"

 

    Write-Host "`n接続テスト: $testUrl" -ForegroundColor Yellow

    $res = HttpGetWithRetry -client $client -url $testUrl -headersMap $headersMap -maxTries 3 -waitSec 2

    Write-Host ("Status: {0}" -f $res.status)

 

    # JSON判定

    $isJson = $false

    $tmp = $null

    try { $tmp = $res.body | ConvertFrom-Json; $isJson = $true } catch {}

    if (-not $isJson) {

        Write-Host "`n[応答がJSONではありません。Cloudflare等のHTMLの可能性。全文出力します]" -ForegroundColor Red

        Write-Host $res.body

        throw "テスト応答がJSONではありません。ブラウザで通った cURL を再取得してください。"

    }

 

    Write-Host ("接続OK. test returned items? {0}" -f ($tmp.items -ne $null)) -ForegroundColor Green

 

    # 一覧取得 → 全件

    $all = @()

    $offset = 0

    $limit  = 28

    while ($true) {

        $u = "$testBase/backend-api/conversations?offset=$offset&limit=$limit&order=updated&is_archived=false&is_starred=false"

        $r = HttpGetWithRetry -client $client -url $u -headersMap $headersMap -maxTries 3 -waitSec 2

        $j = $null

        try { $j = $r.body | ConvertFrom-Json } catch {

            Write-Warning "一覧取得でJSON以外の応答。status: $($r.status)"

            Write-Host $r.body

            throw "一覧取得がJSONで返りませんでした。"

        }

        if (-not $j.items -or $j.items.Count -eq 0) { break }

        $all += $j.items

        $offset += $limit

        $tot = if ($j.PSObject.Properties.Name -contains "total") { $j.total } else { "?" }

        Write-Host ("一覧取得: {0}/{1}" -f $all.Count, $tot)

    }

 

    Write-Host ("総会話件数: {0}" -f $all.Count) -ForegroundColor Cyan

 

    # 各会話の詳細を取得して保存

    foreach ($conv in $all) {

        $id = $conv.id

        $detailUrl = "$testBase/backend-api/conversation/$id"

        Write-Host ("取得中: {0}" -f $id)

        $r = HttpGetWithRetry -client $client -url $detailUrl -headersMap $headersMap -maxTries 3 -waitSec 2

 

        $obj = $null

        try { $obj = $r.body | ConvertFrom-Json } catch {

            Write-Warning "会話詳細がJSONで返らずスキップ: $id(status: $($r.status))"

            Write-Host $r.body

            continue

        }

 

        Save-Conversation -obj $obj -conv $conv -id $id -outputDir $outputDir

    }

 

    Write-Host "`n=== 完了 ===" -ForegroundColor Green

 

} catch {

    Write-Host "`n[致命的エラー]" -ForegroundColor Red

    Write-Host $_.Exception.Message

    try {

        if ($_.Exception.Response) {

            $sr = New-Object System.IO.StreamReader($_.Exception.Response.GetResponseStream())

            $body = $sr.ReadToEnd()

            Write-Host "`n[応答本文]" -ForegroundColor DarkYellow

            Write-Host $body

        }

    } catch {}

}