GraphQL, a great tool to store data

GraphQL, a great tool to store data

I built a small PHP script to populate my site’s database with anime data. the goal was to have a local, always up-to-date catalog of popular anime without having to enter everything by hand.

The AniList Importer

I needed a reliable source of anime data (titles, descriptions, scores, cover images…) that I could pull automatically instead of maintaining it manually. AniList exposes a free public GraphQL API with exactly the kind of data I was after, so I built a script around it. The idea is simple: query AniList’s Page endpoint for anime sorted by popularity, then save each result into a local MySQL table. Since the API paginates results, the script walks through as many pages as needed (with a safety limit) until either there’s no more data or it hits that limit.

How does it work ?

1. talking to the AniList API

function anilistRequest(string $query, array $variables = []): array {
    $payload = json_encode([
        'query' => $query,
        'variables' => $variables,
    ]);

    $ch = curl_init('https://graphql.anilist.co');
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($ch, CURLOPT_HTTPHEADER, [
        'Content-Type: application/json',
        'Accept: application/json',
    ]);
    curl_setopt($ch, CURLOPT_POST, true);
    curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);

    $response = curl_exec($ch);
    if ($response === false) {
        throw new RuntimeException('Erreur cURL : ' . curl_error($ch));
    }
    curl_close($ch);

    $data = json_decode($response, true);
    if (isset($data['errors'])) {
        throw new RuntimeException('Erreur AniList : ' . json_encode($data['errors']));
    }

    return $data['data'] ?? [];
}

This function is the only place that actually talks to AniList. It takes a GraphQL query and its variables, packages them as JSON, and sends them as a POST request through cURL. Once the response comes back, it’s decoded from JSON. If the API returns an errors field, the function throws an exception right away instead of letting bad data slip through silently. Otherwise, it returns just the data part of the response, which is all the rest of the script cares about.

2. saving an anime to the database

function saveAnime(array $media): void {
    $pdo = getPDO();
    $sql = "
        REPLACE INTO anime
            (id, title_english, description, episodes, status, season, season_year, cover_image, average_score, mean_score)
        VALUES
            (:id, :english, :description, :episodes, :status, :season, :seasonYear, :coverImage, :averageScore, :meanScore)
    ";
    $stmt = $pdo->prepare($sql);
    $stmt->execute([
        ':id' => $media['id'],
        ':english' => $media['title']['english'] ?? null,
        ':description' => $media['description'] ?? null,
        ':episodes' => $media['episodes'] ?? null,
        ':status' => $media['status'] ?? null,
        ':season' => $media['season'] ?? null,
        ':seasonYear' => $media['seasonYear'] ?? null,
        ':coverImage' => $media['coverImage']['large'] ?? null,
        ':averageScore' => $media['averageScore'] ?? null,
        ':meanScore' => $media['meanScore'] ?? null,
    ]);
}

This function takes a single media entry returned by AniList and writes it into the anime table. I used REPLACE INTO on purpose: since id is the AniList id and acts as the primary key, running the import again simply overwrites existing rows with fresh data instead of creating duplicates or failing on conflict. Most fields use the null coalescing operator (??) because AniList doesn’t always return every field for every anime, and I’d rather store null than crash the whole import over one missing description.

3. paging through the results

$perPage = 50;
$page = 1;
$maxPages = 10; // limite à 10 pages, change selon besoin

do {
    $result = anilistRequest($query, [
        'page' => $page,
        'perPage' => $perPage,
    ]);

    $pageInfo = $result['Page']['pageInfo'] ?? [];
    $medias = $result['Page']['media'] ?? [];

    if (empty($medias)) {
        break;
    }

    foreach ($medias as $media) {
        saveAnime($media);
        echo "Importé : {$media['title']['english']} (id {$media['id']})\n";
    }

    $page++;
    if ($page > $maxPages) {
        break;
    }
} while (!empty($pageInfo['hasNextPage']));

This is where everything comes together. The loop requests one page of 50 anime at a time, in order of popularity, and saves each one with saveAnime(). Two safety nets keep it from running away: if a page comes back with no media at all, the loop stops immediately, and if it ever goes past maxPages (10 by default), it stops too. Otherwise, it keeps going as long as AniList’s pageInfo.hasNextPage says there’s more to fetch. Right now it caps out at 500 anime (10 pages × 50), which was plenty to seed the database — bumping maxPages is a one-line change if I want to pull more later.