はじめに

はてブのRSSをブックマーク数でフィルタリングして購読するようになってから気になっていたのが、重複している記事の存在だ。
今までずーっとフィード側をなんとかしないといけないのかなと思っていたのだが、Feedly側で重複する記事を既読にするというアイデアを知った。

参考サイト
feedlyで広告と重複エントリを既読にするアプリ2017年4月期 - thrakt’s tech blog

これなら簡単に作れそうだったので、自分はPHPで用意することにした。

Feedly APIのアクセストークン

Feedlyをごにょごにょすることになるので、Feedly APIを利用することになる。
FeedlyのAPIは、OAuth2.0の認証でアクセストークンを取得できるっぽい。
出来ればずーっと使える開発者用のトークンを画面からサクッと発行してくれないかなと思い色々探してみた。
ずばり開発者トークンなるものがあり、画面上からサクサク作れるっぽいのだけども、有効期限が1ヶ月で手動更新ということで却下。
じゃあしょうがないので通常のOAuth2.0の認証フローでいきますかとアプリケーションの作り方を調べてみると、どうやら申請制らしく個人の無料ユーザーの利用は難そうなことが分かった。
こりゃ無理かなと思っていたところ、サンドボックス用のクライアントIDとシークレットが公開されているので、個人利用の人はそっちを使ってくれって感じらしい。
本当にそれで良いのかわからないが、とりあえずこれでいくことにした。

参考サイト
Feedly APIメモ

ということで、

  • 最初に手動でアクセストークンとリフレッシュトークンを取得
  • アクセストークンの有効期限は1週間
  • リフレッシュトークンは破棄しなければ無期限で利用可能
  • アクセスする度にリフレッシュトークンを利用して新しいアクセストークン取得しそれを利用

という戦略でいくことにした。
今のところ問題なく動いている。

Feedly APIで出来ること

以下のことを出来ることを確認。

10000件も未読を貯めないので、

  • 1000件取得した中で未読の重複する記事を見つける
  • 1件を未読状態に残して後は全部既読に
  • 上記の処理を1時間に1回行う

という感じにしてみた。
このロジックだと、フィードが読み込まれるタイミングによって、既に読んだ記事は既読にならないケースも発生してしまうが、そのあたりは許容することに。

記事の元URLの抽出

重複している記事かどうかは、元記事のURLで判断したいのだが、肝心のURLがサクッと取得できなかった。
Entries APIで書かれてるレスポンスが全ての記事で揃っているかというとそうではなく、元記事のURLが安定的に取得できなくて困った。
とりあえずよくわからないので、こんな感じで取得するようにした。

<?php

/**
 * @return array
 */
private function getAllEntries(): array
{
    // ここで1000件の記事を配列で取得している
    $items = $this->api->streamsContentsGlobalAll()['items'];

    return array_reduce($items, function($res, $item) {
        $url = '';
        if (empty($url) && !empty($item['alternate'][0]['href'])) {
            $url = $item['alternate'][0]['href'];
        }
        if (empty($url) && !empty($item['canonicalUrl'])) {
            $url = $item['canonicalUrl'];
        }
        if (empty($url)) {
            return $res;
        }
        if (array_key_exists($url, $res) === false) {
            $res[$url] = [];
        }
        $res[$url][] = $item;

        return $res;
    }, []);
}

観ての通り、alternatecanonicalUrlのどちらに値がセットされていればそれを使うという戦略。
これで9割以上の記事のURLが取得できるようになった。
ただ、これでも取得できない記事がたまーにあるようなので、それは今のところ無視している。

最終的に出来た2つのクラス

API

FeedlyのAPI操作するクラス。

<?php

namespace App\Feedly;

use GuzzleHttp\Exception\GuzzleException;
use GuzzleHttp\Client;

class API
{
    /**
     * @var Client
     */
    private $client;

    /**
     * @var string
     */
    private $userId;

    /**
     * @var mixed
     */
    private $accessToken;

    /**
     * API constructor.
     * @param string $userId
     * @param string $refreshToken
     * @throws GuzzleException
     */
    public function __construct(string $userId, string $refreshToken)
    {
        $this->client = new Client(['verify' => false]);
        $this->userId = $userId;
        $this->accessToken = $this->authTokenRefreshToken($refreshToken)['access_token'];
    }

    /**
     * @param string $refreshToken
     * @return array
     * @throws GuzzleException
     */
    public function authTokenRefreshToken(string $refreshToken)
    {
        // client_idとclient_secretはサンドボックス用
        $response = $this->client->request('POST', 'https://cloud.feedly.com/v3/auth/token', [
            'form_params' => [
                'client_id' => 'feedly',
                'client_secret' => '0XP4XQ07VVMDWBKUHTJM4WUQ',
                'grant_type' => 'refresh_token',
                'refresh_token' => $refreshToken,
            ]
        ]);

        return json_decode($response->getBody(), true);
    }

    /**
     * @return array
     * @throws GuzzleException
     */
    public function streamsContentsGlobalAll()
    {
        $response = $this->client->request('GET', sprintf('https://cloud.feedly.com/v3/streams/contents'), [
            'headers' => [
                'Content-Type": "application/json',
                'Authorization' => sprintf('Bearer %s', $this->accessToken),
            ],
            'query' => [
                'streamId' => sprintf('user/%s/category/global.all', $this->userId),
                'count' => 1000,
            ],
        ]);

        return json_decode($response->getBody(), true);
    }

    /**
     * @param array $entryIds
     * @return array
     * @throws GuzzleException
     */
    public function markersMarkAsReadFeeds(array $entryIds)
    {
        $response = $this->client->request('POST', sprintf('https://cloud.feedly.com/v3/markers'), [
            'headers' => [
                'Content-Type": "application/json',
                'Authorization' => sprintf('Bearer %s', $this->accessToken),
            ],
            'body' => json_encode([
                'action' => 'markAsRead',
                'type' => 'entries',
                'entryIds' => $entryIds,
            ]),
        ]);

        return json_decode($response->getBody(), true);
    }
}

Feeldy

重複している記事を既読にするクラス。

<?php

namespace App\Feedly;

class Feedly
{
    /**
     * @var API
     */
    private $api;

    public function __construct(API $api)
    {
        $this->api = $api;
    }

    /**
     * @throws \GuzzleHttp\Exception\GuzzleException
     */
    public function markDuplicatedItemAsRead()
    {
        $all = $this->getAllEntries();

        $duplicated = array_filter($all, function(array $items) {
            return count($items) > 1;
        }, ARRAY_FILTER_USE_BOTH);

        foreach ($duplicated as $url => $items) {
            $unread = array_filter($items, function(array $item) {
                return $item['unread'];
            });

            // leave unread item at least one
            array_shift($unread);
            if (count($unread) === 0) {
                continue;
            }

            $entryIds = array_map(function(array $item) {
                return $item['id'];
            }, $unread);
            $this->api->markersMarkAsReadFeeds($entryIds);
        }
    }

    /**
     * @return array
     * @throws \GuzzleHttp\Exception\GuzzleException
     */
    private function getAllEntries(): array
    {
        $items = $this->api->streamsContentsGlobalAll()['items'];

        return array_reduce($items, function($res, $item) {
            $url = '';
            if (empty($url) && !empty($item['alternate'][0]['href'])) {
                $url = $item['alternate'][0]['href'];
            }
            if (empty($url) && !empty($item['canonicalUrl'])) {
                $url = $item['canonicalUrl'];
            }
            if (empty($url)) {
                return $res;
            }
            if (array_key_exists($url, $res) === false) {
                $res[$url] = [];
            }
            $res[$url][] = $item;

            return $res;
        }, []);
    }
}

おわりに

実際に動かしてみると、重複している記事というのは1日あたり2-3記事程度で少ないものだった。
けど、今までは重複しないようにフィードの登録を遠慮していたところがあったので、今度からは少しアグレッシブに登録してみるつもり。