> ## Documentation Index
> Fetch the complete documentation index at: https://docs.sorsa.io/llms.txt
> Use this file to discover all available pages before exploring further.

# マーケティングキャンペーンの確認

# APIでTwitterのアクションを確認する：フォロー、リツイート、コメント、引用

X（旧Twitter）の報酬付きキャンペーンでは、アカウントのフォロー、投稿のリツイート、コメント、コミュニティ参加などを参加者に求めます。公平に報酬を配るには、各参加者が申告どおりに行動したか確認する必要があります。手作業では少人数までしか対応できず、自己申告のチェックボックスだけではボットや不正を招きます。

Sorsa APIの確認用エンドポイントでは、「このユーザーは対象アカウントをフォローしたか」「この投稿をリツイートしたか」「コメントしたか」「コミュニティに参加したか」を調べられます。各確認はAPI呼び出しで明確な真偽値または状態を返し、検証可能で記録を確認できるデータに基づくクエスト、プレゼント企画、紹介プログラム、エンゲージメントキャンペーンを構築できます。

このガイドでは、各確認エンドポイントのコードと、全体を組み合わせたパイプラインを説明します。新規アカウントにはカード不要の無料リクエスト100件があり、全エンドポイントで使えるため、プラン選択前に一連の流れを試作できます。

> **注：** 追加のワークフローと全体の実装例は、ブログの[Twitterのアクション確認API：キャンペーン完全ガイド](https://api.sorsa.io/blog/twitter-engagement-verification-api)を参照してください。

***

## 利用できる確認

確認できるアクション、対応エンドポイント、確認できない項目を整理します。

| アクション          | エンドポイント                   | メソッド | 戻り値                                              |
| :------------- | :------------------------ | :--- | :----------------------------------------------- |
| アカウントをフォローした   | `/check-follow`           | POST | `{"follow": true/false}`                         |
| ツイートをリツイートした   | `/check-retweet`          | POST | `{"retweet": true/false}`                        |
| ツイートを引用した      | `/check-quoted`           | POST | `{"status": "quoted" / "retweet" / "not_found"}` |
| ツイートにコメントした    | `/check-comment`          | GET  | `{"commented": true/false}`                      |
| コミュニティのメンバーである | `/check-community-member` | POST | `{"is_member": true/false}`                      |

**確認できないもの：** 「いいね」です。Xは2024年に「いいね」を非公開にしたため、公式を含むどのAPIでも、特定ユーザーが特定ツイートに「いいね」したかを確認できません。上記5つのアクションを使ってキャンペーンを設計してください。

***

## 確認1：アカウントをフォローしたか

「@YourBrandをフォローしてプレゼント企画に参加」といった最も一般的なタスクです。

**エンドポイント：** `POST /v3/check-follow`

「user\_2がuser\_1をフォローしているか」を確認します。`user_1`をブランド（フォロー対象）、`user_2`を参加者にしてください。

### 最小限の例

```bash theme={null}
curl -X POST https://api.sorsa.io/v3/check-follow \
  -H "ApiKey: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "username_1": "YourBrand",
    "username_2": "participant_handle"
  }'
```

レスポンス：

```json theme={null}
{
  "follow": true,
  "user_protected": false
}
```

### パラメーター

それぞれの側について識別子を1つだけ指定します。

| 対象                    | 選択肢（1つを指定）                             |
| :-------------------- | :------------------------------------- |
| ブランド（`user_1`、フォロー対象） | `username_1`、`user_link_1`、`user_id_1` |
| 参加者（`user_2`）         | `username_2`、`user_link_2`、`user_id_2` |

### Python

```python theme={null}
import requests

API_KEY = "YOUR_API_KEY"
BASE = "https://api.sorsa.io/v3"
HEADERS = {"ApiKey": API_KEY, "Content-Type": "application/json"}

def check_follow(brand_handle: str, participant_handle: str) -> dict:
    resp = requests.post(
        f"{BASE}/check-follow",
        headers=HEADERS,
        json={"username_1": brand_handle, "username_2": participant_handle},
        timeout=15,
    )
    resp.raise_for_status()
    return resp.json()


result = check_follow("YourBrand", "participant123")
if result["follow"]:
    print("Follow verified.")
elif result.get("user_protected"):
    print("Account is private; follow cannot be confirmed.")
else:
    print("Not following.")
```

`user_protected`が`true`なら参加者は非公開アカウントで、フォロー関係を確認できません。

***

## 確認2：ツイートをリツイートしたか

「この投稿をリツイートして参加」というタスクです。1リクエストで最大100件のリツイートを調べ、それ以上ある場合はページネーションを使います。

**エンドポイント：** `POST /v3/check-retweet`

### パラメーター

| パラメーター                               | 型      | 必須     | 説明                     |
| :----------------------------------- | :----- | :----- | :--------------------- |
| `tweet_link`                         | string | はい     | 確認対象のツイートURLまたはID。     |
| `username` / `user_link` / `user_id` | string | はい（1つ） | 参加者の識別子を1つだけ指定。        |
| `next_cursor`                        | string | いいえ    | 100件を超えるリツイートを調べるカーソル。 |

### Python

```python theme={null}
def check_retweet(tweet_link: str, participant_handle: str) -> bool:
    cursor = None
    for _ in range(5):  # check up to 500 retweets total
        body = {"tweet_link": tweet_link, "username": participant_handle}
        if cursor:
            body["next_cursor"] = cursor
        resp = requests.post(f"{BASE}/check-retweet", headers=HEADERS, json=body, timeout=15)
        resp.raise_for_status()
        data = resp.json()
        if data["retweet"]:
            return True
        cursor = data.get("next_cursor")
        if not cursor:
            return False
    raise RuntimeError("Retweet verification incomplete: page limit reached")
```

各呼び出しは最新の100リツイートを調べます。多くのキャンペーンでは、参加者が開始直後にリツイートして直近の範囲に入るため、1回で十分です。人気の投稿で早期のリツイートを探す場合は、`next_cursor`で先のページを調べてください。

***

## 確認3：ツイートを引用したか

「感想を添えてこの投稿を引用」というタスクです。`/check-quoted`は引用と通常のリツイートを区別し、状態を文字列で返します。

**エンドポイント：** `POST /v3/check-quoted`

### Python

```python theme={null}
def check_quoted(tweet_link: str, participant_handle: str) -> dict:
    resp = requests.post(
        f"{BASE}/check-quoted",
        headers=HEADERS,
        json={"tweet_link": tweet_link, "username": participant_handle},
        timeout=15,
    )
    resp.raise_for_status()
    return resp.json()


data = check_quoted("https://x.com/YourBrand/status/1234567890", "participant123")

if data["status"] == "quoted":
    print(f"Quote verified! They wrote: {data['text']}")
elif data["status"] == "retweet":
    print("They retweeted but did not quote.")
else:
    print("No quote or retweet found.")
```

### レスポンス

```json theme={null}
{
  "status": "quoted",
  "date": "2026-03-10 14:22:09",
  "text": "This is amazing, everyone should check this out!",
  "user_protected": false
}
```

`status`は3種類です。`"quoted"`は引用済み、`"retweet"`は本文を加えずリツイート済み、`"not_found"`はどちらも未検出です。引用がある場合は日時と本文も含まれ、最低文字数、必須ハッシュタグ、不適切な表現の除外などの品質確認に使えます。

```python theme={null}
def quote_is_acceptable(quote_text: str, min_length: int = 30, required_hashtag: str = None) -> bool:
    if len(quote_text.strip()) < min_length:
        return False
    if required_hashtag and required_hashtag.lower() not in quote_text.lower():
        return False
    return True
```

***

## 確認4：ツイートにコメントしたか

「この投稿にコメント」というタスクです。確認用エンドポイントの中で、POSTではなくGETを使うのはこれだけです。

**エンドポイント：** `GET /v3/check-comment`

### パラメーター（クエリ文字列）

| パラメーター                               | 型      | 必須     | 説明              |
| :----------------------------------- | :----- | :----- | :-------------- |
| `tweet_link`                         | string | はい     | ツイートのURLまたはID。  |
| `username` / `user_link` / `user_id` | string | はい（1つ） | 参加者の識別子を1つだけ指定。 |

### Python

```python theme={null}
def check_comment(tweet_link: str, participant_handle: str) -> dict:
    resp = requests.get(
        f"{BASE}/check-comment",
        headers={"ApiKey": API_KEY},
        params={"tweet_link": tweet_link, "username": participant_handle},
        timeout=15,
    )
    resp.raise_for_status()
    return resp.json()


data = check_comment("https://x.com/YourBrand/status/1234567890", "participant123")

if data["commented"]:
    print(f"Comment verified: {data['tweet']['full_text'][:100]}")
else:
    print("No comment found.")
```

`commented`が`true`なら、コメント自体の完全な`tweet`オブジェクト（本文、エンゲージメント指標、日時）が含まれます。返信の有無だけでなく、最低文字数、必須ハッシュタグ、絵文字だけの返信の除外などの条件を確認できます。

```python theme={null}
def comment_is_acceptable(comment: dict, min_length: int = 20, required_keyword: str = None) -> bool:
    text = comment.get("full_text", "").strip()
    if len(text) < min_length:
        return False
    if required_keyword and required_keyword.lower() not in text.lower():
        return False
    if len(text.split()) < 3:
        return False
    return True
```

***

## 確認5：コミュニティのメンバーか

参加条件にする前に、現在のコミュニティデータの利用可否を[サポート](https://docs.sorsa.io/ja/support)へ確認してください。[リストとコミュニティ](https://docs.sorsa.io/ja/lists-and-communities)の提供状況についての注記も参照してください。

「Xコミュニティに参加して応募」という、コミュニティ参加を前提とするキャンペーンに使えます。

**エンドポイント：** `POST /v3/check-community-member`

### Python

```python theme={null}
def check_community_member(community_id: str, participant_handle: str) -> bool:
    resp = requests.post(
        f"{BASE}/check-community-member",
        headers=HEADERS,
        json={"community_id": community_id, "username": participant_handle},
        timeout=15,
    )
    resp.raise_for_status()
    return resp.json().get("is_member", False)


is_member = check_community_member("1966045657589813686", "participant123")
print("Member" if is_member else "Not a member")
```

コミュニティIDは、URL（`x.com/i/communities/<id>`）に含まれる数値の文字列です。

***

## キャンペーン確認パイプラインの構築

実際のキャンペーンでは参加者が複数のタスクを行います。次の例は1参加者について5つの確認を実行し、構造化した結果を返して、コメントと引用の品質条件も適用します。

```python theme={null}
from dataclasses import dataclass, field

@dataclass
class CampaignConfig:
    brand_handle: str
    tweet_to_retweet: str
    tweet_to_quote: str
    tweet_to_comment: str
    community_id: str
    required_hashtag: str = ""
    min_quote_length: int = 30
    min_comment_length: int = 20

@dataclass
class ParticipantResult:
    username: str
    follow: bool = False
    retweet: bool = False
    quote: bool = False
    quote_text: str = ""
    comment: bool = False
    comment_text: str = ""
    community: bool = False
    completed: int = field(init=False, default=0)

    def total(self) -> int:
        return sum([self.follow, self.retweet, self.quote, self.comment, self.community])


def verify_participant(username: str, cfg: CampaignConfig) -> ParticipantResult:
    r = ParticipantResult(username=username)

    r.follow = check_follow(cfg.brand_handle, username)["follow"]
    r.retweet = check_retweet(cfg.tweet_to_retweet, username)

    quote_data = check_quoted(cfg.tweet_to_quote, username)
    if quote_data["status"] == "quoted":
        r.quote_text = quote_data.get("text", "")
        r.quote = quote_is_acceptable(r.quote_text, cfg.min_quote_length, cfg.required_hashtag)

    comment_data = check_comment(cfg.tweet_to_comment, username)
    if comment_data.get("commented"):
        r.comment_text = comment_data["tweet"].get("full_text", "")
        r.comment = comment_is_acceptable(comment_data["tweet"], cfg.min_comment_length)

    r.community = check_community_member(cfg.community_id, username)

    r.completed = r.total()
    return r


cfg = CampaignConfig(
    brand_handle="YourBrand",
    tweet_to_retweet="https://x.com/YourBrand/status/111111111",
    tweet_to_quote="https://x.com/YourBrand/status/222222222",
    tweet_to_comment="https://x.com/YourBrand/status/333333333",
    community_id="1966045657589813686",
    required_hashtag="#YourLaunch",
)

result = verify_participant("participant123", cfg)
print(f"@{result.username}: {result.completed}/5 tasks done")
```

5つの確認の最初のページだけなら5リクエストです。リツイートの追加ページと再試行で増えるため、5回は固定費ではなく基本の目安です。ページ数の上限による例外は「確認未完了」であり、タスク未達成の証拠ではありません。

***

## 参加者をまとめて確認する

数千人の参加者にはバッチ処理を使います。次の例はレート制限を守り、参加者ごとにCSVへ書き込みます。障害で進捗を失わず、再開できます。

```python theme={null}
import csv
import time
from pathlib import Path

def verify_campaign_batch(usernames: list[str], cfg: CampaignConfig, output_file: str) -> None:
    fields = ["username", "follow", "retweet", "quote", "comment", "community",
              "completed", "quote_text", "comment_text"]

    already_done = set()
    out_path = Path(output_file)
    if out_path.exists():
        with out_path.open() as f:
            already_done = {row["username"] for row in csv.DictReader(f)}

    mode = "a" if out_path.exists() else "w"
    with out_path.open(mode, newline="") as f:
        writer = csv.DictWriter(f, fieldnames=fields)
        if mode == "w":
            writer.writeheader()

        for i, username in enumerate(usernames):
            if username in already_done:
                continue

            for attempt in range(3):
                try:
                    r = verify_participant(username, cfg)
                    writer.writerow({
                        "username": r.username,
                        "follow": r.follow,
                        "retweet": r.retweet,
                        "quote": r.quote,
                        "comment": r.comment,
                        "community": r.community,
                        "completed": r.completed,
                        "quote_text": r.quote_text,
                        "comment_text": r.comment_text,
                    })
                    f.flush()
                    already_done.add(username)
                    print(f"[{i+1}/{len(usernames)}] @{username}: {r.completed}/5")
                    break
                except RuntimeError as e:
                    print(f"[{i+1}] @{username}: INCOMPLETE {e}")
                    break
                except requests.HTTPError as e:
                    if e.response.status_code == 429:
                        time.sleep(5)
                        continue  # retry the same participant
                    print(f"[{i+1}] @{username}: ERROR {e}")
                    break

            time.sleep(0.25)


participants = open("entries.txt").read().splitlines()
verify_campaign_batch(participants, cfg, "campaign_results.csv")
```

ループは参加者間で待機し、`429`を最大3回再試行しますが、1参加者の確認中にもリツイートの追加ページで呼び出しが増える場合があります。本番のワーカープールでは共通のレートリミッターを使ってください。実際の処理速度はページの深さと応答時間に依存します。未完了や失敗した参加者は、不合格と記録せず再試行できる状態に保ちます。

***

## アカウントの所有確認

参加前に、申告されたXアカウントを本人が所有しているか確認する場合は、次の方法がよく使われます。

1. 一意のコード（例：`VERIFY-a8f3b2`）を生成して参加者に表示します。
2. そのコードを含むツイートを投稿してもらいます。
3. `/user-tweets`で最近の投稿を取得し、コードの有無を確認します。

```python theme={null}
import secrets

def generate_verification_code() -> str:
    return f"VERIFY-{secrets.token_hex(4)}"


def verify_account_ownership(username: str, expected_code: str) -> bool:
    resp = requests.post(
        f"{BASE}/user-tweets",
        headers=HEADERS,
        json={"username": username},
        timeout=15,
    )
    resp.raise_for_status()
    tweets = resp.json().get("tweets", [])

    for tweet in tweets:
        author = tweet.get("user") or {}
        if (author.get("username", "").lower() == username.lstrip("@").lower()
                and not tweet.get("retweeted_status")
                and expected_code in (tweet.get("full_text") or "")):
            return True
    return False


code = generate_verification_code()
print(f"Ask the user to tweet: {code}")
# ... after the user tweets ...
if verify_account_ownership("participant123", code):
    print("Account ownership confirmed.")
```

各確認コードをログイン中の参加者と対象Xアカウントに関連付け、短い有効期限を設け、1回だけ使えるようにしてください。一致した投稿の投稿者と作成日時を照合します。例では投稿者と本文を確認しますが、コードの保存、期限、使い捨て処理はアプリケーション側で実装する必要があります。確認後、参加者はツイートを削除できます。

***

## 不正対策の検討事項

以下は、変更可能な参加条件や確認基準として使ってください。アカウントの経過日数や各種件数だけで、正当なアカウントかどうかは証明できません。

* **最低アカウント年齢。** `/info`でプロフィールを取得し、`created_at`を確認します。多くのボットファームが新規アカウントを使うため、過去30日以内に作成されたアカウントを除外するという基準があります。
* **最低活動量。** `tweets_count`と`followers_count`を確認します。件数が少ないことは追加確認の理由にはなりますが、ボットの証拠ではありません。
* **コメントの品質。** `/check-comment`が返す全文で、最低文字数、必須キーワード・ハッシュタグを確認し、1文字や絵文字だけの返信を除外します。
* **引用の品質。** `/check-quoted`の引用本文に、コメントと同じ品質条件を適用します。
* **完了速度。** 速すぎる完了は調査のきっかけですが、自動化の証拠ではありません。時刻を記録し、不自然に速い場合はフラグを付けます。

```python theme={null}
from datetime import datetime, timezone

def is_legitimate_account(
    username: str,
    min_age_days: int = 30,
    min_tweets: int = 10,
    min_followers: int = 5,
) -> tuple[bool, dict]:
    resp = requests.get(
        f"{BASE}/info",
        headers={"ApiKey": API_KEY},
        params={"username": username},
        timeout=15,
    )
    resp.raise_for_status()
    profile = resp.json()

    created = datetime.fromisoformat(profile["created_at"].replace("Z", "+00:00"))
    age_days = (datetime.now(timezone.utc) - created).days

    checks = {
        "account_age_ok": age_days >= min_age_days,
        "has_tweets": profile.get("tweets_count", 0) >= min_tweets,
        "has_followers": profile.get("followers_count", 0) >= min_followers,
        "not_protected": not profile.get("protected", False),
    }
    return all(checks.values()), checks
```

5つの確認の前に実行してください。`is_legitimate_account`が`False`なら、最終的に除外する参加者について5回の確認を省けます。

***

## 影響力に応じて参加者を評価する

参加者のリーチは同じではありません。フォロワー50,000人からのリツイートは、50人のアカウントよりキャンペーンへの価値が高いと考えられます。`/info`でプロフィールを取得し、フォロワー数で報酬に重みを付けられます。

```python theme={null}
import math

BASE_POINTS = {"follow": 10, "retweet": 15, "quote": 25, "comment": 20, "community": 10}

def get_follower_count(username: str) -> int:
    resp = requests.get(
        f"{BASE}/info",
        headers={"ApiKey": API_KEY},
        params={"username": username},
        timeout=15,
    )
    resp.raise_for_status()
    return resp.json().get("followers_count", 0)


def calculate_weighted_points(result: ParticipantResult) -> dict:
    followers = get_follower_count(result.username)
    # log scaling: 100 followers -> 2x, 10K -> 4x, 1M -> 6x
    multiplier = max(1.0, math.log10(followers + 1))
    total = 0
    breakdown = {}
    for task, base in BASE_POINTS.items():
        if getattr(result, task):
            points = round(base * multiplier)
            breakdown[task] = points
            total += points
    return {"followers": followers, "multiplier": round(multiplier, 2),
            "breakdown": breakdown, "total": total}
```

暗号資産を扱うキャンペーンでは、フォロワー数の倍率を[Sorsa Score](https://docs.sorsa.io/ja/sorsa-score-and-crypto-analytics)に置き換えられます。これは暗号資産分野のKOL、プロジェクト、VCからの認知を測定します。

***

## 「いいね」について

X（Twitter）は2024年に「いいね」を非公開にしました。現在、特定のツイートに誰が「いいね」したかは、Sorsa、公式X API、他の外部ツールを含む公開APIから取得できません。以前「このツイートにいいね」という条件を使っていた場合は、確認可能なリツイートかコメントに置き換えてください。

***

## 次のステップ

* [ツイート検索](https://docs.sorsa.io/ja/search-tweets)：キーワードで関連投稿を見つけ、より広く監視する。
* [メンションの追跡](https://docs.sorsa.io/ja/search-mentions)：キャンペーン由来の言及と自然なブランドへの言及を追跡する。
* [リアルタイム監視](https://docs.sorsa.io/ja/real-time-monitoring)：新しい活動を定期取得し、ほぼリアルタイムでタスクを確認する。
* [フォロワーとフォロー中ユーザー](https://docs.sorsa.io/ja/followers-and-following)：自社フォロワー一覧を参加者と照合する。
* [料金](https://api.sorsa.io/pricing)：キャンペーンの費用を見積もる（全確認の基本は1参加者5リクエスト）。
* [APIリファレンス](https://docs.sorsa.io/ja/api-reference-guide)：全確認エンドポイントの仕様。
