2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【データ収集】PowerShellを使ってZennの記事URLを20万件以上取得してみた

Posted at

はじめに

Zennの記事データを分析したり、技術トレンドを調査したりする際に、全記事のURLを取得したいケースがあります。

最初は https://zenn.dev/articles?page=1 のようにページを順番に辿る方法を試しましたが、100ページまでしか表示されないため、全記事を取得することができませんでした。

そこで別の方法を探したところ、Zennがサイトマップを公開していることがわかりました。サイトマップとは、Webサイトの全ページを検索エンジンに知らせるためのファイルです。このサイトマップを解析することで、20万件以上の記事URLを効率的に取得できることがわかりました。

対象読者

  • PowerShellの基本的な使い方を知っている方
  • Webスクレイピングやデータ収集に興味がある方
  • Zennの記事データをプログラムやAIで活用したい方

環境

  • Windows 10/11
  • PowerShell 5.1以降

サイトマップを利用したアプローチ

Zennは https://zenn.dev/sitemaps/_index.xml でサイトマップインデックスを公開しています。このファイルには、記事・本・ユーザー・トピックなどのサイトマップファイルへのリンクが含まれています。

<?xml version="1.0" encoding="UTF-8"?>
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
  <sitemap>
    <loc>https://zenn.dev/sitemaps/articles_1.xml.gz</loc>
    <lastmod>2025-03-08T12:00:00+09:00</lastmod>
  </sitemap>
  <sitemap>
    <loc>https://zenn.dev/sitemaps/articles_2.xml.gz</loc>
    <lastmod>2025-03-08T12:00:00+09:00</lastmod>
  </sitemap>
  <!-- 他のサイトマップファイル -->
</sitemapindex>

各サイトマップファイルはgzip圧縮されており、展開すると個別のURLリストが得られます。

PowerShellスクリプト

以下のスクリプトは、サイトマップを解析してZennの全コンテンツURLを取得します。

PowerShellスクリプト(クリックで展開)
# Zennまるごとゲッター
# PowerShell 5.1 Compatible

param(
    [string]$OutputFile = "zenn_sitemap_data", # 拡張子は自動で付与されます
    [ValidateSet('csv', 'json', 'txt')]
    [string]$OutputFormat = 'txt' # パラメータ検証を追加
)

# --- 実行前処理 ---
Clear-Host
Write-Host "Zennまるごとゲッター" -ForegroundColor Green

# --- 設定 (ここを編集) ---
# trueに設定した種類のURLを取得します
$ScrapeConfig = @{
    article     = $true
    book        = $true
    user        = $true
    topic       = $true
    publication = $true
    static      = $false # 静的ページはデフォルトで除外
}

# 各リクエスト間の待機時間(ミリ秒)。サーバーへの負荷を軽減します。
[ValidateRange(0, [int]::MaxValue)]
[int]$RequestDelayMs = 500

# HTTPリクエスト時に送信するUser-Agent
$UserAgent = "ZennMarugotoGetter/2.0 (PowerShell Script)"

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

# 指定されたバイト配列をgzip形式として展開し、文字列として返します。
function Expand-GzipContent {
    param([byte[]]$GzipBytes)
    try {
        $inputStream = New-Object System.IO.MemoryStream(,$GzipBytes)
        $gzipStream = New-Object System.IO.Compression.GzipStream($inputStream, [System.IO.Compression.CompressionMode]::Decompress)
        $outputStream = New-Object System.IO.MemoryStream
        $gzipStream.CopyTo($outputStream)
        
        # リソース管理の徹底
        $result = [System.Text.Encoding]::UTF8.GetString($outputStream.ToArray())
        $outputStream.Dispose()
        $gzipStream.Dispose()
        $inputStream.Dispose()
        
        return $result
    }
    catch {
        Write-Warning "Gzip展開エラー: $($_.Exception.Message)"
        return $null
    }
}

# 指定されたURLからデータをダウンロードします。失敗した場合はリトライします。
function Download-WithRetry {
    param(
        [string]$Uri,
        [int]$MaxRetries = 3,
        [int]$RetryDelaySec = 2
    )

    for ($attempt = 1; $attempt -le $MaxRetries; $attempt++) {
        try {
            # WebClient → Invoke-WebRequest移行
            $response = Invoke-WebRequest -Uri $Uri -UserAgent $UserAgent -UseBasicParsing -TimeoutSec 30
            return $response.Content
        }
        catch [System.Net.WebException] {
            # HTTP status codeの適切な処理
            if ($_.Exception.Response.StatusCode -eq 404 -or $_.Exception.Response.StatusCode -eq 403) {
                Write-Warning "アクセス不可 ($($_.Exception.Response.StatusCode)): $Uri (スキップします)"
                return $null
            }
        }
        catch {
            if ($attempt -lt $MaxRetries) {
                Write-Warning "ダウンロードに失敗しました (試行 $attempt/$MaxRetries)。$RetryDelaySec 秒後に再試行します..."
                Start-Sleep -Seconds $RetryDelaySec
            }
            else {
                Write-Error "$MaxRetries 回試行しましたが、$Uri のダウンロードに失敗しました。エラー: $($_.Exception.Exception.Message)"
                return $null
            }
        }
    }
    return $null
}

# --- メイン処理 ---
# .NETの圧縮ライブラリをロード
Add-Type -AssemblyName System.IO.Compression

# パフォーマンス改善のため、効率的なリストオブジェクトを使用
$results = New-Object System.Collections.Generic.List[psobject]
$sitemapIndexUrl = "https://zenn.dev/sitemaps/_index.xml"

try {
    # サイトマップインデックスを取得
    $indexContent = Download-WithRetry -Uri $sitemapIndexUrl
    if (-not $indexContent) { throw "サイトマップインデックスのダウンロードに失敗しました。" }
    
    # XML処理の堅牢性向上
    try {
        [xml]$indexXml = $indexContent
        if (-not $indexXml -or -not $indexXml.sitemapindex) {
            throw "無効なサイトマップインデックス構造です。"
        }
    }
    catch {
        Write-Error "サイトマップインデックスのXML解析に失敗しました: $($_.Exception.Message)"
        exit 1
    }
    
    $sitemapUrls = $indexXml.sitemapindex.sitemap.loc
    Write-Host "`n処理対象のサイトマップを $($sitemapUrls.Count) 件見つけました。" -ForegroundColor Cyan
    
    # 各サイトマップを処理
    $totalProcessed = 0
    foreach ($sitemapUrl in $sitemapUrls) {
        $totalProcessed++
        
        # プログレスバーを更新
        Write-Progress -Activity "サイトマップを処理中" -Status "[$totalProcessed/$($sitemapUrls.Count)] $sitemapUrl" -PercentComplete (($totalProcessed / $sitemapUrls.Count) * 100)

        # 設定に基づいて不要なサイトマップをスキップ
        $type = $sitemapUrl -replace ".+/(.+?)\d*\.(xml|gz).*", "$1"
        if (-not $ScrapeConfig.ContainsKey($type) -or !$ScrapeConfig[$type]) {
            continue
        }

        # サーバーへの配慮
        Start-Sleep -Milliseconds $RequestDelayMs

        # サイトマップ本体をダウンロード(リトライ処理込み)
        $sitemapContent = Download-WithRetry -Uri $sitemapUrl
        if (-not $sitemapContent) { continue }
        
        # Gzip展開の処理
        if ($sitemapUrl.EndsWith(".gz")) {
            # バイナリデータとして再取得してGzip展開
            try {
                $binaryResponse = Invoke-WebRequest -Uri $sitemapUrl -UserAgent $UserAgent -UseBasicParsing -TimeoutSec 30
                $sitemapContent = Expand-GzipContent -GzipBytes $binaryResponse.Content
            }
            catch {
                Write-Warning "Gzipファイルの処理に失敗しました: $sitemapUrl"
                continue
            }
        }
        
        if ([string]::IsNullOrWhiteSpace($sitemapContent)) { 
            Write-Warning "空のコンテンツをスキップします: $sitemapUrl"
            continue 
        }

        # XMLをパースし、URLを抽出(堅牢性向上)
        try {
            [xml]$sitemapXml = $sitemapContent
            if (-not $sitemapXml -or -not $sitemapXml.urlset) {
                Write-Warning "無効なXML構造をスキップします: $sitemapUrl"
                continue
            }
        }
        catch {
            Write-Warning "XML解析エラー ($sitemapUrl): $($_.Exception.Message)"
            continue
        }

        foreach ($url in $sitemapXml.urlset.url) {
            $loc = $url.loc
            $lastmod = if ($url.lastmod) { $url.lastmod } else { "" }
            
            # URLの形式に応じてデータを整形
            $obj = $null
            switch -Regex ($loc) {
                "^https://zenn\.dev/([^/]+)/articles/([^/]+)" { $obj = [PSCustomObject]@{ Type = 'Article'; Creator = $matches[1]; Slug = $matches[2]; URL = $loc; LastMod = $lastmod } }
                "^https://zenn\.dev/([^/]+)/books/([^/]+)"    { $obj = [PSCustomObject]@{ Type = 'Book'; Creator = $matches[1]; Slug = $matches[2]; URL = $loc; LastMod = $lastmod } }
                "^https://zenn\.dev/topics/([^/]+)"          { $obj = [PSCustomObject]@{ Type = 'Topic'; Creator = ''; Slug = $matches[1]; URL = $loc; LastMod = $lastmod } }
                "^https://zenn\.dev/p/([^/]+)"               { $obj = [PSCustomObject]@{ Type = 'Publication'; Creator = ''; Slug = $matches[1]; URL = $loc; LastMod = $lastmod } }
                "^https://zenn\.dev/([^/]+)$"                { if ($loc -notmatch '/(articles|books)/') { $obj = [PSCustomObject]@{ Type = 'User'; Creator = $matches[1]; Slug = ''; URL = $loc; LastMod = $lastmod } } }
            }
            if ($obj) { 
                $null = $results.Add($obj) # 戻り値抑制
            }
        }
    }
    Write-Progress -Activity "サイトマップを処理中" -Completed
}
catch {
    Write-Error "致命的なエラーが発生しました: $($_.Exception.Message)"
    exit 1
}

# --- 結果の出力 ---
if ($results.Count -gt 0) {
    Write-Host "`n抽出完了! $($results.Count) 件の項目が見つかりました。" -ForegroundColor Green
    
    # 設定に応じてファイル形式を決定
    $fullOutputFile = "$OutputFile.$OutputFormat"
    
    try {
        if ($OutputFormat -eq 'csv') {
            # BOMなしUTF-8出力
            $csvContent = $results | ConvertTo-Csv -NoTypeInformation
            [System.IO.File]::WriteAllLines($fullOutputFile, $csvContent, [System.Text.Encoding]::UTF8)
        }
        elseif ($OutputFormat -eq 'json') {
            # BOMなしUTF-8出力
            $jsonContent = $results | ConvertTo-Json -Depth 5
            [System.IO.File]::WriteAllText($fullOutputFile, $jsonContent, [System.Text.Encoding]::UTF8)
        }
        elseif ($OutputFormat -eq 'txt') {
            # BOMなしUTF-8出力
            $urlList = $results | Select-Object -ExpandProperty URL
            [System.IO.File]::WriteAllLines($fullOutputFile, $urlList, [System.Text.Encoding]::UTF8)
        }
        
        Write-Host "結果を $fullOutputFile に保存しました。" -ForegroundColor Green
    }
    catch {
        Write-Error "ファイル出力に失敗しました: $($_.Exception.Message)"
        exit 1
    }
}
else {
    Write-Host "`n現在の設定では項目が見つかりませんでした。" -ForegroundColor Yellow
}

Write-Host "`n処理が完了しました。" -ForegroundColor Green

使用方法

1. PowerShellスクリプトファイルの作成

  1. メモ帳を開く(Windowsキーでメモ帳と検索)
  2. 上記のスクリプトコードをコピーして、メモ帳に貼り付け
  3. ファイル > 名前を付けて保存 を選択
  4. ファイル名を ZennGetter.ps1 に設定
    • 重要:ファイルの種類を「すべてのファイル(.)」に変更
    • 拡張子 .ps1 が正しく付くように注意
    • 文字コードは「UTF-8」を選択(Windows 10以降は自動でUTF-8)
  5. デスクトップなど、わかりやすい場所に保存

2. 実行用バッチファイルの作成

  1. 再びメモ帳を開く
  2. 以下の内容を入力:
@echo off
powershell -ExecutionPolicy Bypass -File "ZennGetter.ps1"
pause
  1. ファイル > 名前を付けて保存 を選択
  2. ファイル名を run.bat に設定
    • 重要:ファイルの種類を「すべてのファイル(.)」に変更
    • 拡張子 .bat が正しく付くように注意
    • 文字コードは「UTF-8」を選択
  3. PowerShellスクリプトと同じフォルダに保存

3. 実行

  1. run.bat をダブルクリック
  2. コマンドプロンプトが開き、処理が開始されます
  3. 完了すると zenn_sitemap_data.txt ファイルが同じフォルダに作成されます

初回実行時のエラーについて
「実行ポリシーが制限されています」のようなエラーが出た場合:

  1. Windowsキー + R を押す
  2. powershell と入力してEnter
  3. 以下のコマンドを入力してEnter:
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
  1. Y を入力してEnter
  2. PowerShellを閉じて、再度バッチファイルを実行

実行結果

スクリプトを実行すると、以下のような統計が表示されます:

処理対象のサイトマップを 19 件見つけました。
抽出完了! 205384 件の項目が見つかりました。
結果を zenn_sitemap_data.txt に保存しました。

出力されるファイルには、1行につき1つのURLが記録されます:

https://zenn.dev/username/articles/article-slug
https://zenn.dev/username/books/book-slug
https://zenn.dev/topics/react
https://zenn.dev/username

取得データの活用例

基本的な分析

Windowsのコマンドプロンプトで実行:

# 特定技術の記事数を調べる
findstr /c:"react" zenn_sitemap_data.txt | find /c /v ""
findstr /c:"vue" zenn_sitemap_data.txt | find /c /v ""

PowerShellで実行:

# より詳細な分析
(Get-Content "zenn_sitemap_data.txt" | Select-String "react").Count
(Get-Content "zenn_sitemap_data.txt" | Select-String "vue").Count

コンテンツの分類

CSV形式で出力すれば、ExcelやPowerShellで詳細な分析ができます:

CSV形式での出力方法:

  1. スクリプト内の $OutputFormat = 'txt'$OutputFormat = 'csv' に変更
  2. 再実行すると zenn_sitemap_data.csv が作成されます

PowerShellでの分析:

# CSVファイルを読み込んで分析
$data = Import-Csv "zenn_sitemap_data.csv"

# タイプ別の件数を確認
$data | Group-Object Type | Sort-Object Count -Descending

# 記事数の多いユーザーTop10
$data | Where-Object Type -eq "Article" | Group-Object Creator | Sort-Object Count -Descending | Select-Object -First 10

記事コンテンツの一括収集と加工

取得したURLリストを基に、記事の実際のコンテンツを収集することも可能です。PowerShellのInvoke-WebRequestコマンドレットを使って、各URLからHTMLコンテンツを取得し、テキストとして保存できます。

# PowerShellで記事コンテンツを取得する例(抜粋)
# この処理はZennのサーバーに負荷をかける可能性があるため、慎重に実行してください。

$urls = Get-Content "zenn_sitemap_data.txt" | Select-Object -First 10 # 例として最初の10件
$outputDir = ".\zenn_contents"
New-Item -ItemType Directory -Force -Path $outputDir

foreach ($url in $urls) {
    try {
        $fileName = ($url -replace "^https?://", "") -replace "/", "_" # URLをファイル名に変換
        Invoke-WebRequest -Uri $url -OutFile "$outputDir\$fileName.html"
        Start-Sleep -Milliseconds 1000 # サーバー負荷軽減のため1秒待機
    }
    catch {
        Write-Warning "記事のダウンロードに失敗しました: $($url) - $($_.Exception.Message)"
    }
}

その他の活用アイデア

  • 技術トレンドの時系列分析: 記事の投稿日時とURLを組み合わせることで、特定の技術に関する記事の投稿数の推移を分析し、その技術の人気度の変化を可視化
  • 学習リソースのキュレーション: 自分の学習目標に合わせて、関連性の高い記事をURLリストから抽出し、自分だけの学習パスを作成
  • コンテンツ監視・アラート: 新しい記事の投稿を定期的にチェックし、関心のある分野の更新があった場合に通知を受け取るシステムを構築
  • Zennコミュニティ分析: ユーザーURLやトピックURLを活用し、Zennコミュニティ内での影響力のあるユーザーや、活発なトピックを特定

技術的なポイント

エラーハンドリング

スクリプトでは以下のエラーハンドリングを実装しています:

  • ネットワークエラー時の自動リトライ機能
  • 404や403エラーの適切な処理
  • XML解析エラーの処理
  • リソースの適切な解放

パフォーマンス最適化

  • System.Collections.Generic.List を使用してメモリ効率を向上
  • Gzip圧縮ファイルの適切な展開処理
  • プログレスバーによる進捗表示

サーバー負荷への配慮

  • リクエスト間の待機時間設定
  • User-Agent の適切な設定
  • タイムアウト値の設定

注意事項

  • スクリプトにはサーバー負荷軽減のため、リクエスト間に500msの待機時間を設けています
  • 全データの取得には数分から数十分程度の時間がかかります
  • 出力ファイルは数十MBになる場合があります
  • サイトマップの構造が変更された場合、スクリプトの修正が必要になる可能性があります
  • 記事コンテンツの自動収集を行う際は、Zennの利用規約を必ず確認し、適切な範囲で実施してください

まとめ

Zennのサイトマップを活用することで、ページネーションの制限を回避し、効率的に全記事URLを取得できました。このアプローチは他のサイトマップを公開しているWebサイトにも応用可能です。

取得したデータは、技術トレンドの分析、コンテンツの分類、学習用データセットの構築など、様々な用途に活用できます。PowerShellの強力なテキスト処理機能と組み合わせることで、さらに高度な分析も可能になります。

参考リンク

2
1
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
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?