LinkedIn公開データのスクレイピング完全ガイド:法的境界とプロキシ活用法

LinkedInの公開プロファイル、求人データを合法的に収集する方法を解説。hiQ Labs判例、CFAAの枠組み、住宅用プロキシの重要性、Python実装例を網羅。

LinkedIn公開データのスクレイピング完全ガイド:法的境界とプロキシ活用法

重要な免責事項: 本記事は教育目的のみで提供され、法的助言ではありません。LinkedInの利用規約、CFAA(米国コンピュータ詐欺・乱用法)、GDPR、その他適用されるすべての法律を遵守してください。スクレイピングを行う前に、必ず弁護士に相談してください。

はじめに:LinkedIn公開データへのアクセス

LinkedInは世界最大のプロフェッショナルネットワークであり、8億人以上のメンバー情報、企業データ、求人情報を保持しています。採用ツール開発者や市場調査チームにとって、これらの公開データは貴重なリソースです。しかし、LinkedInはボット検知とデータ保護において業界でも最も高度な対策を講じています。

本ガイドでは、公開されているデータのみを対象とした、法的・倫理的な境界を尊重したデータ収集方法を解説します。具体的には、ログイン不要でアクセス可能な公開プロファイルURL、企業ページ、求人情報の取得方法、そしてLinkedInの厳格なボット検知を回避するための住宅用プロキシの活用法を詳しく説明します。

法的背景:hiQ Labs v. LinkedIn判例とCFAA

hiQ Labs事件の概要

2017年、LinkedInはスタートアップ企業hiQ Labsに対し、同社のプラットフォームからデータをスクレイピングすることを停止するよう通知しました。hiQはLinkedInの公開プロファイルデータを分析し、企業に従業員のスキルギャップや離職リスクに関する洞察を提供していました。

hiQは差止命令を求めて訴え、連邦地裁はhiQに有利な判決を下しました。判事は、公開されているデータのスクレイピングはCFAAに違反しないとの見解を示しました。これは重要な先例です:

  • 公開データ=「許可なくアクセス」ではない:誰でもブラウザで閲覧できるデータは、CFAAの意味において「保護されたコンピュータ」への不正アクセスには該当しない
  • 利用規約違反≠連邦法違反:WebサイトのToSに違反しても、それだけで刑事責任を問われるわけではない
  • しかし民事責任は別問題:ToS違反は契約違反として民事訴訟の対象になり得る

2022年、連邦控訴裁判所はこの判決を支持し、公開データのスクレイピングはCFAA違反ではないと確認しました。しかし、この判決は公開データに限定されており、ログインが必要なデータやプライベート情報には適用されません。

重要な区別: hiQ判例は「公開されている」データのみを対象としています。ログインセッション、Sales Navigator、プライベートメッセージなど、認証が必要なデータをスクレイピングすることは、全く異なる法的リスクを伴います。

CFAAの現状と不確実性

CFAAは1986年に制定された法律で、当初は政府や金融機関のコンピュータを保護することを目的としていました。「許可なくアクセスする」という条項の解釈は長年議論の的であり、過度に広範な解釈は日常的なセキュリティ研究やジャーナリズム、競合分析まで犯罪化しかねないとの批判があります。

2021年のVan Buren v. United States判決で、連邦最高裁判所はCFAAの適用範囲を制限しました。この判決は「アクセス権があるが目的が不正」というケースにはCFAAが適用されないと判断しました。hiQ事件との関連では、公開データへのアクセス権自体は争われていないため、この判決はスクレイピングの合法性をさらに支持する方向に働きます。

しかし、以下の点に注意が必要です:

  • LinkedInは依然としてToS違反を主張して民事訴訟を起こす可能性がある
  • EUではGDPRやデータベース権が別の法的枠組みを形成する
  • 判例は米国のものであり、他国では異なる法律が適用される
  • 技術的な回避策(CAPTCHA突破、認証情報の不正取得)は別の違法行為になり得る

ログイン不要でアクセス可能なデータ

LinkedInで認証なしでアクセスできる公開データには以下が含まれます:

公開プロファイルURL

https://www.linkedin.com/in/{username} 形式のURLは、多くの場合、ログインなしで部分的に閲覧可能です。表示される情報は:

  • 氏名と職歴の概要
  • 現在の役職と企業名
  • 教育背景(一部)
  • 接続数の概要(「500+ connections」など)

ただし、LinkedInは頻繁にUIを変更し、非ログインユーザーに表示される情報量を制限する傾向があります。また、プロファイル所有者のプライバシー設定によっては、完全に非公開になっている場合もあります。

企業ページ

https://www.linkedin.com/company/{company-name} は通常、ログインなしでアクセス可能です。取得できる情報:

  • 企業概要と業界
  • 従業員数の規模
  • 本拠地と拠点
  • 最近の投稿(一部)
  • 関連求人(リンク)

公開求人情報

LinkedIn Jobsは最もアクセスしやすい公開データの一つです。https://www.linkedin.com/jobs/search/ エンドポイントは、ログインなしで利用できます:

  • 求人タイトルと企業名
  • 勤務地
  • 雇用形態(正社員、契約、リモート等)
  • 給与範囲(掲載されている場合)
  • 求人説明の概要
  • 応募リンク

求人データは市場調査、競合分析、採用ツール開発において特に価値が高く、比較的アクセスしやすいため、倫理的なスクレイピングの好例です。

なぜ住宅用プロキシが不可欠なのか

LinkedInのボット検知システムは極めて高度です。データセンタープロキシを使用した場合、ほぼ確実に検知され、ブロックされます。その理由を理解しましょう。

LinkedInのフィンガープリンティング技術

LinkedInは複数のシグナルを組み合わせてボットを検知します:

  • IPレピュテーション:データセンターのIPアドレス帯域は既知のリストと照合される。AWS、GCP、AzureなどのクラウドIPは即座にフラグが立つ
  • 行動パターン:人間らしい閲覧パターン(ページ滞在時間、スクロール、クリック)と機械的な高速アクセスを区別
  • ブラウザフィンガープリント:User-Agent、Canvas、WebGL、フォント、プラグインの組み合わせでデバイスを特定
  • リクエスト頻度:同一IPからの短時間の大量リクエストは即座に制限
  • TCP/IPフィンガープリント:パケットヘッダーの特徴でプロキシ使用を検知

データセンタープロキシの問題点

データセンタープロキシがLinkedInで機能しない理由:

問題説明
ASN検知データセンターのASN(自律システム番号)は公開されており、LinkedInはこれをブロックリスト化している
IPレンジ全体のブロック1つのIPがフラグ立つと、そのサブネット全体がブロックされることがある
一貫したフィンガープリントデータセンターIPは通常、類似したTCP/IPスタック特性を持ち、検知が容易
レピュテーションの欠如データセンターIPにはブラウジング履歴がなく、不審に見える

住宅用プロキシの利点

住宅用プロキシは実際のISPから割り当てられたIPアドレスを使用するため、通常の家庭用インターネット接続と区別がつきません:

  • 正規のASN:Comcast、Verizon、NTTなどのISPのASNを使用
  • 地理的分散:世界中の住宅IPからアクセス可能
  • IPローテーション:リクエストごとに異なるIPを使用可能
  • ブラウジング履歴:多くの住宅IPには既存のブラウジング履歴があり、信頼性が高い

ProxyHatの住宅用プロキシを使用する場合、以下のフォーマットで接続します:

http://username:password@gate.proxyhat.com:8080

geoターゲティングで特定の国や都市からのアクセスをシミュレート:

http://user-country-US:password@gate.proxyhat.com:8080
http://user-country-JP:password@gate.proxyhat.com:8080

Python + Playwrightでの実装例

以下は、住宅用プロキシを使用してLinkedIn求人データを収集するPythonスクリプトの例です。倫理的なレート制限、リアルなブラウザコンテキスト、エラーハンドリングを実装しています。

前提条件のインストール

pip install playwright asyncio
playwright install chromium

スクレイパーの実装

import asyncio
import random
import json
from playwright.async_api import async_playwright
from datetime import datetime

# ProxyHat設定
PROXY_HOST = "gate.proxyhat.com"
PROXY_PORT = 8080
PROXY_USER = "your_username"  # 実際のユーザー名に置き換え
PROXY_PASS = "your_password"  # 実際のパスワードに置き換え

class LinkedInJobScraper:
    def __init__(self, country_code="US"):
        self.country_code = country_code
        self.base_url = "https://www.linkedin.com/jobs/search/"
        self.results = []
        
    async def create_browser_context(self, playwright):
        """リアルなブラウザコンテキストを作成"""
        proxy_config = {
            "server": f"http://{PROXY_HOST}:{PROXY_PORT}",
            "username": f"{PROXY_USER}-country-{self.country_code}",
            "password": PROXY_PASS
        }
        
        browser = await playwright.chromium.launch(
            headless=True,
            proxy=proxy_config,
            args=['--disable-blink-features=AutomationControlled']
        )
        
        # 人間らしいUser-Agentとビューポート
        context = await browser.new_context(
            viewport={"width": 1920, "height": 1080},
            user_agent=(
                "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
                "AppleWebKit/537.36 (KHTML, like Gecko) "
                "Chrome/120.0.0.0 Safari/537.36"
            ),
            locale="en-US",
            timezone_id="America/New_York"
        )
        
        return browser, context
    
    async def human_like_delay(self):
        """人間らしい遅延を追加"""
        delay = random.uniform(2.0, 5.0)  # 2-5秒のランダム遅延
        await asyncio.sleep(delay)
    
    async def scrape_jobs(self, keywords, location, max_pages=3):
        """求人データをスクレイピング"""
        async with async_playwright() as playwright:
            browser, context = await self.create_browser_context(playwright)
            page = await context.new_page()
            
            try:
                for page_num in range(max_pages):
                    start = page_num * 25  # LinkedInは1ページ25件
                    url = (
                        f"{self.base_url}?"
                        f"keywords={keywords}&"
                        f"location={location}&"
                        f"start={start}"
                    )
                    
                    print(f"[{datetime.now()}] ページ {page_num + 1} を取得中...")
                    
                    await page.goto(url, wait_until="networkidle", timeout=60000)
                    await self.human_like_delay()
                    
                    # 求人カードを抽出
                    job_cards = await page.query_selector_all(".job-card-container")
                    
                    for card in job_cards:
                        job_data = await self.extract_job_data(card)
                        if job_data:
                            self.results.append(job_data)
                    
                    # ページ間の遅延(重要:レート制限を回避)
                    await asyncio.sleep(random.uniform(3.0, 8.0))
                    
                    # IPローテーションをシミュレートするため、
                    # 新しいコンテキストを作成するか、セッションを維持
                    # 実運用では、定期的にIPをローテーションすべき
                    
            except Exception as e:
                print(f"エラーが発生: {e}")
            finally:
                await browser.close()
        
        return self.results
    
    async def extract_job_data(self, card):
        """求人カードからデータを抽出"""
        try:
            title_el = await card.query_selector(".job-card-list__title")
            company_el = await card.query_selector(".job-card-container__company-name")
            location_el = await card.query_selector(".job-card-container__metadata-item")
            link_el = await card.query_selector("a[href*='/jobs/view/']")
            
            title = await title_el.inner_text() if title_el else ""
            company = await company_el.inner_text() if company_el else ""
            location = await location_el.inner_text() if location_el else ""
            link = await link_el.get_attribute("href") if link_el else ""
            
            return {
                "title": title.strip(),
                "company": company.strip(),
                "location": location.strip(),
                "url": f"https://www.linkedin.com{link}" if link.startswith("/") else link,
                "scraped_at": datetime.now().isoformat()
            }
        except Exception as e:
            print(f"抽出エラー: {e}")
            return None

# 実行例
async def main():
    scraper = LinkedInJobScraper(country_code="US")
    jobs = await scraper.scrape_jobs(
        keywords="software engineer",
        location="San Francisco",
        max_pages=3
    )
    print(json.dumps(jobs, indent=2, ensure_ascii=False))

if __name__ == "__main__":
    asyncio.run(main())

重要な実装ポイント

  • レート制限:各リクエスト間に2-8秒の遅延を設け、人間らしい閲覧パターンを模倣
  • プロキシ設定:ProxyHatの住宅用プロキシを使用し、geoターゲティングでアクセス元を分散
  • ブラウザフィンガープリント:一般的なUser-Agent、ビューポートサイズ、ロケールを使用
  • エラーハンドリング:タイムアウトと例外処理を実装
  • データの最小化:必要なデータのみを抽出

求人スクレイピングの詳細

/jobs/search/エンドポイントの構造

LinkedIn Jobsの検索URLは以下のパラメータをサポートしています:

https://www.linkedin.com/jobs/search/?
  keywords={search_terms}&
  location={location}&
  f_JT={job_type}&
  f_WT={work_type}&
  f_E={experience_level}&
  start={offset}

主要なフィルターパラメータ:

パラメータ説明
f_JTF, C, P, T, I, Vフルタイム、契約、パートタイム、一時、インターン、ボランティア
f_WT1, 2, 3オンサイト、リモート、ハイブリッド
f_E1, 2, 3, 4エントリーレベル、アソシエイト、シニア、ディレクター
f_C会社ID特定企業でフィルター
start0, 25, 50...ページネーション(25件単位)

ページネーションの処理

LinkedInは最大1,000件(40ページ)までの検索結果を表示します。それ以上のデータを取得するには、フィルターを細分化して複数のクエリを実行する必要があります:

# フィルターを細分化してデータを分割取得
search_combinations = [
    {"keywords": "software engineer", "location": "San Francisco", "f_JT": "F"},
    {"keywords": "software engineer", "location": "San Francisco", "f_JT": "C"},
    {"keywords": "software engineer", "location": "New York", "f_JT": "F"},
    # ... 他の組み合わせ
]

for params in search_combinations:
    jobs = await scraper.scrape_jobs(**params, max_pages=10)
    # データを保存
    await asyncio.sleep(60)  # クエリ間の長い休止

Node.jsでの実装例

PlaywrightのNode.js版を使用した例:

const { chromium } = require('playwright');

const PROXY_HOST = 'gate.proxyhat.com';
const PROXY_PORT = 8080;
const PROXY_USER = 'your_username';
const PROXY_PASS = 'your_password';

async function scrapeLinkedInJobs(keywords, location, maxPages = 3) {
  const browser = await chromium.launch({
    headless: true,
    proxy: {
      server: `http://${PROXY_HOST}:${PROXY_PORT}`,
      username: `${PROXY_USER}-country-US`,
      password: PROXY_PASS
    }
  });

  const context = await browser.newContext({
    viewport: { width: 1920, height: 1080 },
    userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
  });

  const page = await context.newPage();
  const results = [];

  try {
    for (let pageNum = 0; pageNum < maxPages; pageNum++) {
      const start = pageNum * 25;
      const url = `https://www.linkedin.com/jobs/search/?keywords=${encodeURIComponent(keywords)}&location=${encodeURIComponent(location)}&start=${start}`;
      
      console.log(`[${new Date().toISOString()}] Fetching page ${pageNum + 1}...`);
      await page.goto(url, { waitUntil: 'networkidle', timeout: 60000 });
      
      // 人間らしい遅延
      await new Promise(r => setTimeout(r, 2000 + Math.random() * 3000));
      
      const jobCards = await page.$$('.job-card-container');
      
      for (const card of jobCards) {
        const title = await card.$eval('.job-card-list__title', el => el.textContent?.trim() || '');
        const company = await card.$eval('.job-card-container__company-name', el => el.textContent?.trim() || '');
        const jobLocation = await card.$eval('.job-card-container__metadata-item', el => el.textContent?.trim() || '');
        
        results.push({ title, company, location: jobLocation, scrapedAt: new Date().toISOString() });
      }
      
      // ページ間の遅延
      await new Promise(r => setTimeout(r, 3000 + Math.random() * 5000));
    }
  } catch (error) {
    console.error('Error:', error);
  } finally {
    await browser.close();
  }

  return results;
}

// 実行
scrapeLinkedInJobs('data scientist', 'Tokyo', 3)
  .then(jobs => console.log(JSON.stringify(jobs, null, 2)));

スクレイピングしてはいけないデータ

法的・倫理的な境界を超えるデータ収集は避けるべきです。以下は明確にNGです:

ログインセッションが必要なデータ

  • 完全なプロファイル情報:ログイン後にのみ表示される詳細な職歴、スキル、推薦文
  • 接続ネットワーク:誰が誰とつながっているか
  • メッセージとInMail:プライベート通信は完全に不可
  • アクティビティデータ:投稿、コメント、いいねの履歴(認証が必要)

Sales NavigatorとPremium機能

Sales NavigatorはLinkedInの有料製品であり、そのデータへの無断アクセスは明確に法的リスクが高い:

  • 詳細なリード検索とフィルター
  • 拡張されたプロファイル情報
  • インサイトとアラート機能
  • 組織階層データ
警告: Sales Navigatorのデータをスクレイピングすることは、hiQ判例の保護範囲を超える可能性が高いです。これらは有料サービスの背後にあり、「公開」されていないと合理的に解釈できます。

プライベート設定のデータ

ユーザーがプライバシー設定で非公開にした情報を取得しようとすることは、倫理的にも法的にも問題があります:

  • プロファイルの可視性を制限しているユーザーのデータ
  • 「公開しない」設定の情報
  • 限定公開の投稿や活動

禁止すべき技術的アプローチ

  • 認証情報の不正取得:フィッシング、クレデンシャルスタッフィング、セッションハイジャック
  • CAPTCHA突破:自動CAPTCHA解決サービスの使用
  • 脆弱性の悪用:APIのバグや未公開エンドポイントへのアクセス
  • なりすまし:虚偽のアカウント作成によるデータアクセス

公式LinkedIn APIの代替手段

LinkedInは公式APIを提供しており、多くのユースケースではこれが最善の選択肢です:

LinkedIn Marketing API

マーケティングと広告目的向け:

  • 広告キャンペーンの管理
  • オーディエンスデータ
  • アナリティクスとレポート

利用にはLinkedInパートナーシップが必要です。

LinkedIn Talent Solutions API

採用・HR用途向け:

  • 求人投稿の管理
  • 応募者データの取得
  • 採用ツールとの統合

これはLinkedIn RecruiterまたはLinkedIn Jobs製品の契約が必要です。

LinkedIn Share API

コンテンツ共有向け:

  • 投稿の作成と共有
  • 組織ページの管理

API vs スクレイピングの比較

側面公式APIスクレイピング
法的確実性高い不確実(公開データに限定)
データ範囲契約に依存公開データのみ
安定性公式サポートありUI変更で破損のリスク
コスト高額な契約が必要プロキシ費用のみ
レート制限明確な制限自己管理が必要
倫理的リスク低い境界の判断が必要

倫理的スクレイピングのベストプラクティス

robots.txtの尊重

LinkedInのrobots.txtを確認し、その指示に従ってください。ただし、robots.txtは法的拘束力がないとする見解もありますが、倫理的なスクレイピングでは尊重すべきです。

データの最小化

必要なデータのみを収集し、過度なデータ収集を避けてください:

  • 目的を明確に定義する
  • 必要最小限のフィールドのみを抽出
  • 個人を特定できる情報は最小限に
  • データ保持期間を設定

レート制限とサーバーへの配慮

LinkedInのサーバーに過度な負荷をかけないよう、適切なレート制限を設けてください:

  • リクエスト間に適切な遅延を入れる(最低2-3秒)
  • ピーク時間を避ける
  • 並行接続数を制限する
  • 429エラーを受け取った場合は速やかにバックオフ

透明性と説明責任

  • データの収集目的を文書化
  • 社内で倫理ガイドラインを策定
  • 定期的に法的コンプライアンスを確認
  • 問題が発生した場合は速やかに対応

いつ公式APIを使うべきか

以下の場合は、スクレイピングではなく公式APIの使用を検討してください:

  • 商業製品の開発:顧客に提供するサービスの場合、法的確実性が重要
  • 大量のデータが必要:APIの方が効率的で信頼性が高い
  • プライベートデータへのアクセス:認証が必要なデータはAPI経由のみ合法
  • 長期的なプロジェクト:スクレイピングはUI変更で破損するリスクが高い
  • 企業としてのコンプライアンス:リスク許容度を慎重に評価

Key Takeaways

  • 公開データのみを対象に:hiQ判例は公開データのスクレイピングをCFAA違反ではないとしたが、保護範囲は限定的
  • 住宅用プロキシが必須:LinkedInのボット検知は高度で、データセンタープロキシではほぼ確実にブロックされる
  • レート制限を厳守:人間らしい閲覧パターンを模倣し、サーバーへの負荷を最小限に
  • 法的境界を超えない:ログインが必要なデータ、Sales Navigator、プライベート設定の情報は収集しない
  • 公式APIを検討:商業利用や大量データ収集ではAPIの方が長期的には安全で効率的
  • 倫理的ガイドラインを策定:組織としてデータ収集の方針を明確に

まとめ

LinkedInの公開データスクレイピングは、法的・倫理的境界を尊重すれば可能ですが、細心の注意が必要です。hiQ Labs v. LinkedIn判例は公開データの収集に一定の法的保護を与えましたが、それは「公開されている」データに限定され、LinkedInの利用規約違反として民事訴訟のリスクは残ります。

技術的には、住宅用プロキシの使用が不可欠です。ProxyHatのようなプロバイダーが提供する住宅用プロキシを使用することで、LinkedInの高度なボット検知を回避し、安定的なデータ収集が可能になります。

最終的には、データ収集の目的、規模、リスク許容度を評価し、スクレイピングと公式APIのどちらが適切かを慎重に判断してください。倫理的で持続可能なデータ収集は、長期的な成功への鍵です。

ProxyHatの住宅用プロキシについて詳しくは、料金ページまたはロケーション一覧をご覧ください。

始める準備はできましたか?

AIフィルタリングで148か国以上、5,000万以上のレジデンシャルIPにアクセス。

料金を見るレジデンシャルプロキシ
← ブログに戻る