ふぁメモ

主に技術系のメモをしたいけどやっぱり適当日記。たまにPHPコード載ってるけどメモ書き程度のスクリプトなのでそのまま使っちゃダメ。

Amazonに投稿したレビューをバックアップしようの巻

Amazonレビューが消されてた話

ブログを使ってガジェットレビューとかやっていこうかなとか思った時期もあるのだけど、だいたいAmazonレビューに投稿してしまうのでほぼ転載するだけになるし別にいいだろうと思っていたのだけど、最近ふと消えているレビューがあることに気付いた。

f:id:fashi:20210504163037p:plain
キャッシュの日付からすると最近

f:id:fashi:20210504163108p:plain
消されていたレビュー内容

Googleのキャッシュにはまだ残っていたので消されたのは最近のようだ。 HDCP云々がまずかったのか他のメーカーを揶揄しているのが悪かったのか原因はわからない。 同じメーカーの新モデルからはHDCPの文字は消えていたりするが、他の同一製品のレビューにある似たようなレビューは消されていない。

あるいは、悪評が多い中の賞賛レビューなのでサクラを疑われたのかもしれない。

f:id:fashi:20210504163241p:plain
似た内容の消えてないレビュー

自分としては気になる機器があるけど18,800円は手が出ないなーと思っていたら見た目が全く同じのが6,480円で売られていたのでダメ元で買ったら普通に使えたぜヒャッホウと思って★5レビューを投稿したのだが、その後まともに動かない・音ズレするという★1レビューが増えたのでサクラに見えるのは致し方ない。こんなことならダメなところを書いて★4にしておけばよかった。

f:id:fashi:20210504165401p:plain
気になるけどやけに高い機器

ちなみにインプレスのAVWatchを読んでいたら小寺信良氏も同じ製品を買っていた。

https://av.watch.impress.co.jp/docs/series/zooma/1306593.html

記事内にもある通り実にありふれた製品である。

f:id:fashi:20210504163135p:plain
AVWatch記事

中華製品はかなり品質にバラつきがあるので個体不良は多いだろうし、あるロットやある代理店の販売したものが全て不良品だったり中身の違う偽物なんてこともありうる。★1レビューをしている人はそういった不良品を掴んでしまったのかもしれない。 Monoqlo誌やthe360.lifeで絶賛されていたから買ったのにハズレだった、なんてこともままあるのだ。

競合他社が売れ筋商品を貶めようとして悪意あるレビューを投稿していたなんて事例もあるくらいなので、Amazonのレビューというのは実際はほとんどアテにならないのだ。

消えた自分のレビューももしかしたら競合他社が競合製品の評価を高くしているレビューを狙い打ちして通報している可能性だってあるやもしれない。

自分としては「手元にある製品はまともに動いています。Bandicamで動かしています。二時間くらいキャプチャした限り音ズレは確認出来ません。OBSは使ってないので知らないです。繋ぎっぱなしにしていますがまだ壊れてはいません。」ということしか言えない。これから購入する人が同じ性能の製品を手にできるかすらわからない。

今も残っているレビューで以前から気になっていたもの

そんな低評価のレビューの中で一際気になるレビューがある。

f:id:fashi:20210504163211p:plain
おかしなレビュー

使わずに返品したってw なんでこれでレビューとして成立するんだよ。

よく製品自体には触れずに配送が遅かったからとか梱包が悪いからとか(他の商品の)サポートの態度が悪かったとかで評価を下げようとしているひどいレビューがあるけど、これも相当だ。悪評を投稿するためだけに購入して返品しているじゃないか。

そして気になる一文が。

レビュー☆5にしたら2000円分ギフト券プレゼントと書かれた紙も同梱されておりました

え、そんなの入ってなかったぞ…。

ただまあこれは販売代理店(販売元)が入れるのだろうから、自分が買った時と違う販売元の商品には入っているのかもしれない。

気になるのはこのレビューが普通に長期間掲載されているということだ。

レビューしてくれたら金券あげます的な紙が入っているのはよくあるので自分もレビューに書いたことがあるが、その時はAmazonからこのレビューは掲載できませんと言われてしまった。

f:id:fashi:20210504163305p:plain
掲載拒否されたレビュー

コミュニティを混乱させる誤解を招く行為になるんだそうである。 確かに★5レビューの並んでいる商品のレビューにあいつらはみんな金をもらっているサクラだという書き込みがあれば一気に評判は落ちるだろう。実際にはそんな事実がないとしても悪魔の証明だし確かめようがない。(逆に実際に紙きれの写真を提示したところで捏造を疑われる可能性もある)

以前買った毛布のレビューには「毛が抜ける」という低評価が散見されたし、以前使っていたパソコン用ユーティリティ製品には「ユーザー登録すると迷惑メールがいっぱい来る」みたいなレビューが付けられていた。もちろん自分の購入した毛布に抜け毛は見られないし、登録したメールアドレスに迷惑メールが来ることもなかった。こういうのは昔からある嫌がらせの手法であろう。それはわかる。

じゃあなんでさっきのレビューはずっと掲載されているのだ。

Amazonレビューも担当者ガチャ次第ということなのかもしれない。 通報しようにも昔はあった理由を記入する欄がなくなってしまったので数が集まらない限りチェックしない気がしてならない。

Amazonに投稿したレビューをバックアップする

で、本題。

Amazonにレビューを投稿したらそれで満足していたが、消される可能性があるなら念のため手元に控えを取っておいたほうがいいだろう。 さすがにいちいち書いた文章のコピーを保存しているほどマメではなかった。消されているものは仕方ないが今見られるものだけでも保存しておこうと思い立ったわけだ。

自分が投稿したレビューの一覧はどこにあるのか。

投稿したレビューは、公開プロフィールより編集または削除できます。

公開プロフィールを見てみると、書き込み履歴という欄があって、自分の投稿したレビューが並んでいる。 しかし「全文を表示する」をクリックしないと全文がでてこないし、商品名も途中で切られてるし、販売終了した製品に至っては何の商品のレビューかもわからない。DOMからはASINと個別ページへのURLしか取得できない。

ええい、もっと再利用しやすい一覧はないのかっ

Chromeの開発者ツールでネットワークタブのXHR見てみると、スクロールして読み込みが発生する度に

https://www.amazon.co.jp/profilewidget/timeline/owner?...

というエンドポイントへのアクセスがある。 ここで取得しているJSONの中身を見るとレビューが全文書いてある上に商品名もフルで入っている。販売終了した商品名も入っていたり(入っていないのもある)する。

f:id:fashi:20210504162906p:plain
プロフィールページをスクロールしながら開発者ツールを確認する

「owner?」で抽出できることを確認して過去の分が全部表示されるようにスクロールしながらこれを右クリックしてHARを保存するとかオブジェクトをコピーしてテキストエディタに貼り付けると目的は果たせそうである。

Previewタブの▼contributionsをcopy valueしてテキストエディタに貼り付けて自分でカンマを入れて次のをくっつけていくのが使いやすいだろうか。あるいはその左上の▼{marketplaceId~}をCopy objectしてcontributionsだけ連結するのがわかりやすいかな。あるいは左ペインの行ごとに右クリックしてCopy→Copy responseしてテキストエディタに貼り付けて改行して1行1レスポンスで貼り付けていくのがいいだろうか。一括で保存できないのがちょっともどかしい(Save all as Har with contentsだとフィルタした結果以外も入る)。

あとはこのJSONを…PHPとかで処理すれば…と思ったけどJSONとして保存したところで目的が果たせたので整形はしなくていいや。

おしまい。

きっと本来はAmazonのdeveloper登録したら適切なAPIが利用できるのだろうけど、普通にブラウズしながら開発者ツールを眺めるだけでも結果は得られるというわけである。

いやーChromeの開発者ツール便利だなー(そこか?)

余談

開発者ツールを眺めているとイロイロ解決する話は数多い。

たとえばInstagramなんか、Facebookに買収されてからAPIの利用申請がとても面倒になってしまって、likeした画像を保存するだけみたいな個人的な趣味ではAPIを利用することができなくなってしまったのだが、ログインしない状態で個別URLを開いて開発者ツールを見るとOGPと共有ボタンのところに必要な情報は全て書いてあったりするのだ。(※Mediaタブで拾えるのは縮小版であってオリジナルではない)

あとTogetterでマンガが公開されてるやつ、普通に見るとページをめくるのが面倒なんだけど、画像クリックすると専用ビューアが開いて全ページ分の画像リストがAjaxで読み込まれるのを拾えたりする。

規約で禁止されてたりするスクレイピング行為をしたりスクレイピングするツールを公開するといろいろ問題がありそうだけど、普通にブラウジングしながら裏で取得してる情報を保存しておくだけなら何も問題はないよね…?

それに内部APIなんて突然仕様が変わって使えなくなるのがオチ…

追記

同じ商品に内容を調整したレビューを再度投稿したところすんなり掲載された。日付は新しいものなのになぜか前のレビューに付いていた「9人のお客様がこれが役に立ったと考えています」という評価がそのまま新しいレビューに付いてきてしまった。はてブでも内容編集して前と中身違うのにスターが付いたままなのなんか申し訳ないなってのあるけど同じ気分になったよ。

mixi_exportの件追記

mixi_exportの件追記

昨日の件の続き。

mixi_exportが途中で落ちたので

mixi側の仕様変更に追従できなかったのではないか?とか、最悪自分で改造して対応…とか思ったけど

#---------------------------------------------------------------------
# ●ログデータを取得
#---------------------------------------------------------------------
my $get_border = 0;
my %years;
@yyyymm = sort { $b <=> $a } @yyyymm;
foreach my $yyyymm (@yyyymm) {
    my $year = substr($yyyymm, 0, 4);
    my $mon  = substr($yyyymm, 4, 2);
    my $int_mon = int($mon);
    if (($yyyymm*100+99) < $get_border) { last; }
    &myprint("■$year$mon月を処理\n");

ここのforeachの下に

foreach my $yyyymm (@yyyymm) {
    # 2010.7より新しい月はスキップ
    if ($yyyymm > 201007){ next; }
    my $year = substr($yyyymm, 0, 4);

とかやって取得できたところはスキップして試したら普通に続きが取得できたので単にネットワークエラーかメモリエラーで落ちたということだろう。ソース付いてて助かるね。 なお既存のファイルが存在すると更新モードになって1ヶ月ぶんしか取得してくれなくなるので落ちた月のファイルは消しておいたほうがよさげ。

追記

EXE版ではなくPL版を動かしていたのでわかったが、

Error : Error response from 'mixi.jp' (status 502)

しばらくアクセスが続くと502で落ちるっぽい。また↑の日付変えれば済む話だけど、

mixi_exportの画像をどうにかするの巻

mixi_export

今更ながらmixiプレミアムに課金してるの無意味っぽいから解約するかなあって思い立ったんですが、昔書いた日記は容量オーバーで消される前に退避しておきたいですね。

以前退会祭になった時分に日記をエクスポートするツールがいろいろあったと思ったのですが、その後の仕様変更(これが多いのでツールがなかなか出ない)で使えなくなってしまったものばかり。

かろうじてmixi_exportはまだ動作するっぽい…。

Proxyでhttps不可だけどサービス一覧とかからログイン可能。 てかCookie取得するだけなら管理者ツールからコピペとかでいい気もします。

しかし…

しかし動いたけど2010年以前の日記が取得できない…これもmixiの仕様変更が悪いのかたまたまそこでメモリ不足になってしまうのか…。ひとまず2010年以前の日記は諦める。

あと画像も回収してくれない。URLを絶対パスに置き換える処理だけしてくれて、保存されたHTML開けばサーバーに残ってる画像は表示されるので、退会する前にPDF化とかしておくのが一般的みたいです。

うーん。画像。。。ChromeのSavePageWEとか使えばページ単位では保存できるが、面倒。 SwiftProxyみたいなProxyでアクセスしたURL全部保存みたいなツール最近はないのかな(これもhttpsだとダメか)。

じゃあ自分で作るしかないかなあ…

というわけで作りました。単純にURLリストアップしてダウンロードすればいい気もしたけど、SavePageWE同様にIMGタグで埋め込まれてる画像をDataURIに置き換えることにします。

本当は全ての画像をDataURIに置き換えるのがベストなんだけど、そうすると同じファイルを読みに行かないようにキャッシュを導入しないといけなくなって面倒なので日記のフォトの画像だけにします。

本当はAタグのリンク先の元画像を拾うほうが高品質なのだけど、雰囲気がわかればいいのでサムネイルだけ拾うことにします。 元画像はそれこそDataURIで埋め込むには大きすぎるので普通にダウンロードするコード書いたほうがいいかもしれないし。

なお写真が今のサーバー(photoserviceなんちゃら)に移動したのは最近の仕様変更なので、昔作ったファイルに対しては動作しない可能性が高いです。

成果物

<?php
/***
 * mixi_exportの生成したHTML内のフォト(imgタグの一部)をDataURIに変換
 *
 *   2021.04.10 fa
 */

// mixi_exportで出力されたログHTMLのあるフォルダ
$path = './log2/';

$mime_types = array('png'=>'image/png', 'jpg'=>'image/jpeg', 'gif'=>'image/gif');

$context = stream_context_create(
    array(
        'http' => array(
            "protocol_version" => "1.1",
            'follow_location' => 1,
            'max_redirects' => 5,
            'timeout' => 60.0,
            'user_agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.190 Safari/537.36',
        ),
        'ssl' => array(
            'verify_peer' => false,
            'verify_peer_name' => false,
        ),
    )
);

if (!is_dir($path)){
    _dlog('log path '.$path.' is not found',0);
    exit;
}

// サブディレクトリ走査
$iterator = new RecursiveIteratorIterator(
    new RecursiveDirectoryIterator(
        $path,
        FilesystemIterator::SKIP_DOTS
        |FilesystemIterator::KEY_AS_PATHNAME
        |FilesystemIterator::CURRENT_AS_FILEINFO
    ), RecursiveIteratorIterator::LEAVES_ONLY
);

// htmlファイルだけチェック
$iterator = new RegexIterator($iterator, "/\.html$/", RecursiveRegexIterator::MATCH);

$i = 1;
foreach($iterator as $pathname => $f){
    $name = $f->getFilename();
    $dir = $f->getPath();
    $path = $f->getPathname();
    $fullpath = realpath($path);

    // 目次ファイルはスキップ
    if ($name == 'index.html'){
        continue;
    }

    // 処理済ファイルスキップ用
    //if (preg_match("/".preg_quote(DIRECTORY_SEPARATOR,'/')."(2010|2011)/",$path)){
    // _dlog("skip ".$path);
    // continue;
    //}

    _dlog($path);

    // 念のため2000マイクロ秒待つ
    usleep(2000);

    // ローカルのHTML読込
    $contents = file_get_contents($path);

    // 置換処理
    $contents2 = preg_replace_callback(
        '/\<img ([^\>]*)src="(https?:\/\/(?:photoservice|classic-imagecluster)[^"]+\.(' . implode('|', array_keys($mime_types)) . '))"/im',
        function ($matches) use ($mime_types,$context) {

            $url = trim($matches[2], '\'\"');
            _dlog($url);

            // サーバアクセス前に2000マイクロ秒待つ
            usleep(2000);

            // リモートファイル取得
            $http_response_header = null;
            $image = file_get_contents($url,false,$context);
            if ($image){
                // DataURIに置換
                return '<img '.$matches[1].'src="data:' . $mime_types[strtolower($matches[3])] . ';base64,' . base64_encode($image).'"';
            }elseif (!$http_response_header){
                // サーバの反応がなかった時、1秒置いて一度だけ再試行
                _dlog("retry",0);
                sleep(1);
                $image = file_get_contents($url,false,$context);
                if ($image) { return '<img '.$matches[1].'src="data:' . $mime_types[strtolower($matches[3])] . ';base64,' . base64_encode($image).'"'; }
            }
            _dlog("ERROR! ".$url,2);
            return $matches[0];
        },
        $contents
    );

    // 置き換えたHTMLだけ保存
    if (strlen($contents) != strlen($contents2)){
        $i++;
        // 元ファイルをリネームしてから保存
        rename($path,$path.'.bak');
        file_put_contents($path,$contents2);

        // 10ファイル処理毎に少し待つ
        if (!($i % 10)){
            _dlog('wait');
            sleep(20);
        }
    }
}
function _dlog($msg,$lv=0){
    echo $msg.PHP_EOL;
    if ($lv) {
        file_put_contents('./error.log',date('Y/m/d-H:i:s ').$msg.PHP_EOL,FILE_APPEND);
    }
    //TODO エラー多い時は中断する処理
}

img2datauri_mixi.php

何も考えずに手書きしたので使われてないコードやクォートのバラつきがありますが手仕事なんてこんなものだよね(汗

追記

途中で落ちた件は仕様変更対応とかではなかった。追記

エラーメッセージに特化した翻訳サイトがあればいいと思う件

(※この記事は思ったことをダラダラ呟いてるだけで何の知見も得られないし何も解決していません。増田でもよかったけどすぐ特定されそうなのでこちらに。)

ライブラリの紹介文でよく見かけるけどいまいち意味が分からない英単語 - Qiita

プログラミングに特化した翻訳サイト欲しいな

2020/12/13 16:00
b.hatena.ne.jp

これで思い出したんだけど、エラーメッセージの翻訳サイトってけっこう重宝されそうな気がするんだ。

というのも、仕事柄よく他のコーダー(※おおむね初級者)からPHPのエラーについて相談を受けることがあるのだけど、誰も彼も英語が嫌いすぎてエラーメッセージすら読んでないみたいなのだ。

相談を受けたものの、原因はエラーメッセージに書かれている通りなので、一体全体どこで詰まって相談してきているのかわからない。 エラーメッセージの内容を日本語で言い直してあげればそれで解決してしまうことがほとんどなのだ。

プログラム言語やPC用ソフトウェアが吐き出す英語なんてもう英語の文法の要素なんか全然なくて、ほぼほぼコンピュータ用語なので、文法とかは中学生レベルの知識で十分なのだけど、彼/彼女ら(以下彼ら)は英文のメッセージを見ただけで拒否反応を起こして見なかったことにしてしまう。

自分の観測範囲が狭いだけ(※サンプル数10くらい)と思いたいのだけど、初級プログラマから抜け出せないコーダーには英語嫌いが多い気がする。

彼らはエラーメッセージを確認した時、何をするか。

(全く読まずに相談してくる輩もいるが)

まずそのままググってしまう。

でもだいたいのエラーは汎用的なエラーメッセージだし、メッセージにはそのプログラムで使われている独自の単語が引用されてしまう場合も多い。

そんな単語の羅列でGoogle検索したところで出てくるのは関係ないプログラムの関係ないエラーか、もしくは何もヒットしない、ひどい時には「-」がついたままコピペして検索するせいでNOT検索になってしまっている場合もある。

ググるにしても重要そうなキーワードだけを拾って検索するでしょうよ普通は…、と思うのだけど、彼らにはどれが重要そうなキーワードなのかがわからない。

コーディング上のエラーではなくそのライブラリを使った時によくあるFAQだったりした場合には同じところで引っかかった人のブログが出てくるので、これでも解決は期待出来るのだけど…。

ググっても解決できない、もしくは大量にヒットした検索結果をつまんで解決しないと、彼らは次に翻訳サイトを利用する。

(そういえば彼らは検索結果のつまみ方も間違ってしまう場合が多い。1番目に公式リファレンスが出てきたならそれを見れば解決しそうなもの(特にPHPのマニュアルは親切だ)なのだが、検索結果画面に表示される公式リファレンスのスニペットはだいたい英語っぽい文字列になっているので、避けてしまうのだ。そして誰が書いたかわからない日本語のブログや翻訳された知恵袋系のサイトを見てしまう。質問を投げかけて解決せず止まっているものや、古いバージョンでしか起こらない記述、説明は正しいのにサンプルコードが間違っているもの等、罠は尽きない。)

最近の機械翻訳は優秀なので、特に詳細なリファレンスページなんかはChromeの翻訳機能でかなり読みやすくなった。

でもエラーメッセージは通常かなり短いし、あまり親切な英文ではないことが多く、機械翻訳ではうまく翻訳できないことが多い。

それで詰まってしまうようだった。

たとえばこんなのだ

Warning: fputcsv() expects parameter 1 to be resource, string given

警告。fputcsv()は1番目のパラメータにリソースを期待していますが、文字列が与えられています。

この時点でリソース(ここではファイルハンドル)が何かわからなくても、引数が間違ってることは想像が付くだろう。あとは関数リファレンスを見ればいいだけだ。

これを自分で読み解かずにGoogleの翻訳に掛けるとこうなる。

警告:fputcsv()は、パラメーター1がリソースであり、文字列が指定されていることを想定しています。

1番目のパラメータとしてリソースを文字列で指定してください、という意味になってしまう。

指定しているよ? 何が間違ってるの? という解釈になってしまっても仕方が無い。

DeepL翻訳でも試してみよう。

警告: fputcsv() はパラメータ 1 がリソース、与えられた文字列であることを期待しています。

あまり変わらない。PHPが吐き出しているエラーメッセージの文法がおかしいのではないか?という気さえしてくる。 ちなみにhoweverとかbutとかを付け足してやれば期待通りの日本語になる。

いつしか彼らもベテランになり、似たようなエラーには何度も遭遇することになるので、英語はわからないけどそのエラーの意味するところはわかります、という状態になることは十分に期待できる。 しかしもっと適切な、システムが吐き出したエラーメッセージをわかりやすい日本語に変換してくれるWebサービスがあったら彼らもすこぶる捗るに違いない気がするのだ。

あるいはエラーメッセージの文例集があるといいのだが、スクリプトエンジンが肥大化していることもありエラーメッセージも多岐に渡ることもあり、なかなか良いサイトが見つけられない(特に日本語となると…)

そもそもPHPが多言語対応ついでにエラーを日本語出力してくれてもいいようなものだが、そういったリソースやプロジェクトは聞き及んだことがない。(jperlくらいかな)

(もっとも、PHPだけの問題でもない。彼らはgitの吐いたAbortという文字列を見逃して手順が正常終了したと思い込んで罠に陥ってしまうこともしばしばあるのだ。gitは正常終了でもログがいくらか出るので普段から読み飛ばす癖がついてしまっているらしい…)

Google翻訳は日々進化しているのでそのうち改善されることを期待したほうが早いだろうか…。

Slack2gmailを改造しようの巻

Slackログのエクスポート

Slackのログの保存って結構面倒です。

昔はIRCクライアントで接続できたので適当なbotやクライアントの設定でログが取れたんですが、なくなっちゃいましたし。

公式でやるにはEnterprise Gridプランを契約してログが管理人に読まれることを常に明示するか、他ユーザーの了承を得ている証明か当局の命令書をSlack社に提出しなければならないとか… プライバシーに関わるとはいえそこまで…っていう。

でもAPIアクセスだとトークン発行したユーザーが参加しているチャンネルなら普通に読めます。 無料版だと1万postを上限に過去のやりとりが消えていくので定期的なログ保存ツールは割と重宝されます。

探すと「slack-dump」みたいな(似たような)名前のツールがいくつか見つかります。

私が愛用しているslack2gmail

ここのfork先の

こちらの方のGoogle Apps Scriptですね。

でも昔から使ってるやつは大丈夫なんですけど、新規に導入しようとすると動かないんですよ。 というかレガシートークンが発行停止になっててもう取得できないっていう。

改造しよう

Permissions

リンク先ではOAuth認証を実装しないとダメみたいなことが書いてありましたが、なんてことはなく、パーミッションを設定してOAuth Access Tokenを使えば改造不要で利用できました。

なんだ、動くじゃん。

Slack側が旧スクリプトに寛容なのかGASがうまいことやってくれてるのかわかりませんが。

共有チャンネルとユーザー名

でもふと気付くと一部のチャンネルが取れていないんです。

他のワークスペースのチャンネル&ユーザーを招待した共有チャンネルってやつですね。

共有チャンネルは channels.history では取得できなくて conversations.history を使う必要があるみたいです。 テストしてみたら出力されている内容は互換性がある感じです。

じゃあ置き換えるだけですね。おっけー。

しかし名前が出ない。

もともとSlackのAPIでは名前はユーザIDが返ってくるのであらかじめ users.list にアクセスしてユーザIDと名前の対照リストを取得した上で置換するって手筈なんですが、共有チャンネルの別のスペースの参加者のぶんは取れないわけです。(ちゃんとドキュメント読んでないのであくまで想像です)

しかしレスポンスの中身をよく見ると、発言したユーザーの情報は user_profile っていうプロパティの中にいろいろ入ってます。 ここから名前を拾えば大丈夫そうです。

しかしそのユーザーが発言するまで取得できません。 取得前に他のユーザーから呼びかけられていたりしますと、名前が紐付けられないわけです。 じゃあ2回走査すればいいかというと、その日は全く発言がなかったりする場合だってあるわけです。

まあ話の流れでわかりそうなので妥協で…というのもナンなので、取得する代わりにデータとして設定しておけるようにしましょう。 新しい人がでてきたらその都度IDと名前をデータに追加していく方向で。

泥臭いですがとりあえず実用的になりました。

スレッド

また一部の発言が取れていないことに気付くわけです。

スレッドにだけ返信したヤツが入っていません。

スレッドは conversaisons.replies にチャンネルIDとスレッドタイムスタンプを投げて取得するらしいです。なんて面倒な。 conversasions.history にオプション1つつけて取得できるようにしてくれてもいいのに。

まあスレッドがあると時系列順ではわかりにくくなってしまうので別々のほうが都合がいいのかな。 てゆーか日付跨ぐ場合どうしよう。とりあえず親発言のタイムスタンプ準拠でいいか。

容量超過時

Gmailのメールにぶち込むと容量制限だか行数制限だかをくらうので、元スクリプトでは設定文字数を超えたぶんは破棄できるようになっていますが、破棄されたぶんは取得できません。

単に最後に取得したメッセージのタイムスタンプを保存して次回APIに投げてるだけなので、破棄されるポイントのタイムスタンプを保存して次回続きから取得できるように改造しましょう。

今思いつきましたが分割して続けて送信するってのもアリですね。まあそれは次回に回すとしましょう。

トークンもプロパティに保存しよう

公開にあたってソースにAPIトークンが載ってると事故りそうなので、今作業中。

でも前述のユーザIDリストは面倒なのでソースに直書きのままです。

…って、standaloneのGASって入力プロンプト出せないんかい。

じゃあ諦めてそのまま公開しよう。

成果物

なにぶん素人の改造なもんでアレな部分が散見されますが、とりあえずの現状報告です。

既知の不具合というか仕様:1日以上前のスレッドについた返信は追えない。

slack2gmail_kai.gs

// Slackログをメールで送信
// base: http://qiita.com/tf-oikawa/items/578d45ed7dcb81d4e2f2

// OAuth Access Token(Slack App の Add features and functionality → Permissions を要確認:
//  usergroups:read, channels:history, channels:read, files:read, groups:history, groups:read, mpim:history, mpim:read, team:read, usergroups:read, users:read, im:read, im:history あたり)

// 一度実行してプロパティに保存されたら空欄OK
var API_TOKEN = '';

// メール送信先
// 一時実行してプロパティに保存されたら空欄OK
var MAIL_TO = '';

// メール件名プレフィクス
var MAIL_TITLE_PREFIX = '[SlackLog]';

// 取り込み最大量(1000件 x 10ページ)
var MAX_HISTORY_PAGINATION = 10;
var HISTORY_COUNT_PER_PAGE = 1000;

// 1メールあたりの最大文字数
var MAX_MAIL_LENGTH = 100000;

// 最大文字数を超えた場合、次回起動時に 1: そこから再開 2:前日分から再開 0: 超えた分は破棄する(前回最終取得時刻分より再開)
// ※再開するタイムスタンプはファイル→プロジェクトプロパティ→スクリプトのプロパティに保存されていて変更可能
var RESUME_TYPE = 2;

// 共有チャンネル用のユーザー名定義(新しく発言した人がいたら[memberdefines]メールが届くので都度追加する)
var presetMemberNames = {
// 'UXXXXXXX': '△△△',
// 'UXXXXXXX': '■■■',
};

//プライベートチャンネルの読み出し 1:する 0:しない
var ENABLE_PRIVATE = 1;

//グループチャット(MPIM)の読み出し 1:する 0:しない
var ENABLE_MPIM = 1;

//DM(IM)の読み出し 1:する 0:しない
var ENABLE_IM = 1;

//参加していないオープンチャンネルの読み出し(1:する 0:しない)
var OPEN_ALL = 0;

// プロパティのインポート/エクスポート 1:する 0:しない (プロパティのexportの文字列を移動先でimportに登録する)
var IMPORT_ENABLE = 0;
var EXPORT_ENABLE = 0;

//dim
var additionalMemberNames = {};
var prop = {};

function StoreLogsDelta() {
    var logger = new SlackChannelHistoryLogger();
    logger.run();
};

var SlackChannelHistoryLogger = (function () {
    function SlackChannelHistoryLogger() {
        this.memberNames = presetMemberNames;
    }

    SlackChannelHistoryLogger.prototype.requestSlackAPI = function (path, params) {
        if (params === void 0) {
            params = {};
        }
        var url = "https://slack.com/api/" + path + "?";
        var qparams = [("token=" + encodeURIComponent(API_TOKEN))];
        for (var k in params) {
            qparams.push(encodeURIComponent(k) + "=" + encodeURIComponent(params[k]));
        }
        url += qparams.join('&');
        Logger.log("==> GET " + url);
        try {
            var resp = UrlFetchApp.fetch(url);
        }catch(e){
            Logger.log("==> Failed");
            return false;
        }
        var data = JSON.parse(resp.getContentText());
        if (data.error) {
            throw "GET " + path + ": " + data.error;
        }
        return data;
    };
    SlackChannelHistoryLogger.prototype.run = function () {
        var _this = this;
        var p = PropertiesService.getScriptProperties().getProperties();
        if (p != null) {
            _this.prop = p;
        } else {
            _this.prop = {};
        }

        if (_this.prop['import'] === undefined){
            _this.prop['import'] = 0;
        }

        if (_this.prop['export'] === undefined){
            _this.prop['export'] = 0;
        }

        if ( (IMPORT_ENABLE) && (_this.prop['import'] !== undefined) && (_this.prop['import'] !== 0) ){
            var import = _this.prop['import'];
            _this.prop = JSON.parse(import);
            _this.prop['import'] = 0;
        }

        if (API_TOKEN == ''){
            if (_this.prop['slack_api_token'] !== undefined){
                API_TOKEN = _this.prop['slack_api_token'];
            }
        }
        _this.prop['slack_api_token'] = API_TOKEN;

        if (MAIL_TO == ''){
            if (_this.prop['mail_address'] !== undefined){
                MAIL_TO = _this.prop['mail_address'];
            }
        }
        _this.prop['mail_address'] = MAIL_TO;

        var usersResp = this.requestSlackAPI('users.list');
        usersResp.members.forEach(function (member) {
            _this.memberNames[member.id] = member.name;
        });
        var teamInfoResp = this.requestSlackAPI('team.info');
        this.teamName = teamInfoResp.team.name;

        var options3 = {};
        options3['types'] = 'public_channel';
        if (ENABLE_PRIVATE){
            options3['types'] += ',private_channel';
        }
        if (ENABLE_MPIM){
            options3['types'] += ',mpim';
        }
        if (ENABLE_IM){
            options3['types'] += ',im';
        }
        var channelsResp = this.requestSlackAPI('conversations.list',options3);

        var title = "";
        var body = "";
        for (var _i = 0, _a = channelsResp.channels; _i < _a.length; _i++) {
            var ch = _a[_i];
            if (ch.is_archived) {
                continue;
            }
            if (ch.is_im){
                ch.name = 'DM:' + ch.user;
                if (_this.memberNames[ch.user]){
                    ch.name = 'DM:' + _this.memberNames[ch.user];
                }
            }
            else if (ch.is_mpim){
                ch.name = 'Group:' + ch.name;
            }
            else if (ch.is_private){
                ch.name = 'Private:' + ch.name;
            }
            else if ((!ch.is_member) && (!OPEN_ALL)) {
                continue;
            }
            else {
                ch.name = 'Public:' + ch.name;
            }

            body = this.importHistoryDelta(ch);
            if (body != "") {
                if (body.length > MAX_MAIL_LENGTH) {
                    body = body.substr(0, MAX_MAIL_LENGTH);
                    body += "\n--- string length over " + MAX_MAIL_LENGTH + " ---\n";
                }
                // send mail
                title = MAIL_TITLE_PREFIX + "[" + ch.name + "] " + _this.formatDate(new Date());
                MailApp.sendEmail({
                    to: MAIL_TO,
                    subject: title,
                    body: body
                });
            }
        }

        if (Object.keys(additionalMemberNames).length){
            body = '';
            Object.keys(additionalMemberNames).forEach(function (key) {
                if (body){
                    body += ",\n";
                }
                body += "\t'" + key + "': '" + additionalMemberNames[key] + "'";
            });
            body += "\n";
            // send mail
            title = MAIL_TITLE_PREFIX + "[memberdefines] " + _this.formatDate(new Date());
            MailApp.sendEmail({
                to: MAIL_TO,
                subject: title,
                body: body
            });
        }

        if (EXPORT_ENABLE){
            _this.prop['export'] = JSON.stringify(_this.prop);
        }

        PropertiesService.getScriptProperties().setProperties(_this.prop);
    };
    SlackChannelHistoryLogger.prototype.importHistoryDelta = function (ch) {
        var _this = this;
        Logger.log("importHistoryDelta " + ch.name + " (" + ch.id + ")");
        var oldestKey = ch.name + '-oldest';
        var oldest = 1;
        if (_this.prop[oldestKey] != null) {
            oldest = _this.prop[oldestKey];
        }
        var options = {};
        if (oldest != null) {
            options['oldest'] = oldest;
        }

        var todayString = _this.formatDate(new Date());
        var messages = this.loadMessagesBulk(ch, options);
        if (messages === false){
            return false;
        }

        //insert threads
        var threadIds = [];
            messages.forEach(function (msg) {
            if (msg.thread_ts){
                if (threadIds.indexOf(msg.thread_ts) === -1){
                    threadIds.push(msg.thread_ts);
                }
            }
        });
        var lastGetReplies_ts = 0;
        var previous_ts = 0;
        for(var i=0; i<threadIds.length; i++){
            var options2 = {};
            options2['channel'] = ch.id;
            options2['ts'] = threadIds[i];
            var resp2 = _this.requestSlackAPI('conversations.replies', options2);
            if (resp2){
                //スレッドの最初のメッセージは既に存在しているものなので削除すべきだが繋がりがわかりにくくなるので残す
                //var first = resp2.messages.shift();
                resp2.messages[0].additional_header = "=================== thread ===================" + "\n#thread.ts: " + _this.formatTs(threadIds[i]);
                messages = messages.concat(resp2.messages);
                previous_ts = threadIds[i];
            }
            else {
                //rate limitになった場合は最後に取得できたスレッドIDを保存
                lastGetReplies_ts = previous_ts;
                break;
            }
        };

        var dateStringToMessages = {};
        messages.forEach(function (msg) {
            msg.is_thread = false;
            if (msg.thread_ts){
                var date = new Date(+msg.thread_ts * 1000);
                if (msg.ts != msg.thread_ts){
                    if ((msg.subtype) && (msg.subtype == 'thread_broadcast')){
                    }
                    else {
                        msg.is_thread = true;
                    }
                }
            }
            else {
                var date = new Date(+msg.ts * 1000);
            }
            var dateString = _this.formatDate(date);
            if (!dateStringToMessages[dateString]) {
                dateStringToMessages[dateString] = [];
            }
            dateStringToMessages[dateString].push(msg);
            if ((msg.ts != 0)&&(!msg.is_thread)){
                _this.prop[oldestKey] = msg.ts;
            }
        });

        var body = "";
        if (dateStringToMessages != null && dateStringToMessages.length != 0) {
            var yesterday_last_ts = 0;
            var previous_ts = 0;
            var last_msg_ts = 0;
            var break_flag = false;
            var debug_info = "";
            var start_info = "";
            if (oldest != null) {
                start_info = "#start msg.ts: " + _this.formatTs(oldest) + "\n";
            }

            for (var dateString in dateStringToMessages) {
                body += "=================== date:[" + dateString + "] ===================\n\n";
                dateStringToMessages[dateString].forEach(function (msg) {
                    if (break_flag){ return true; }
                    var date = new Date(+msg.ts * 1000);
                    var user = msg.username ? msg.username : msg.user;
                    if (_this.memberNames[user]) {
                        user = _this.memberNames[user];
                    }
                    else if (msg.user_profile){
                        _this.memberNames[user] = msg.user_profile.display_name + '(' + user + ')';
                        additionalMemberNames[user] = msg.user_profile.display_name;
                        user = msg.user_profile.display_name + '(' + user + ')';
                    }
                    if (msg.additional_header){
                        body += msg.additional_header + "\n\n";
                    }
                    body += '[' + Utilities.formatDate(date, Session.getScriptTimeZone(), 'yyyy-MM-dd HH:mm:ss') + '] ' +
                        'from:' + user + "\n" +
                        _this.unescapeMessageText(msg.text) + "\n";
                    if (msg.attachments) {
                        var attachments = JSON.stringify(msg.attachments);
                        body += 'attachments:' + _this.unescapeMessageText(attachments) + "\n";
                    }
                    if (msg.files) {
                        var files = JSON.stringify(msg.files);
                        body += 'files:' + _this.unescapeMessageText(files) + "\n";
                    }
                    body += "\n";

                    if (!msg.is_thread){
                        last_msg_ts = msg.ts;
                    }

                    if ((body.length > (MAX_MAIL_LENGTH - 100))|| ( (lastGetReplies_ts) && (last_msg_ts) && (lastGetReplies_ts < last_msg_ts))) {
                        if ((RESUME_TYPE == 1)&&(previous_ts != 0)){
                            _this.prop[oldestKey] = previous_ts;
                        } else if ((RESUME_TYPE == 2)&&(yesterday_last_ts != 0)){
                            _this.prop[oldestKey] = yesterday_last_ts;
                        }
                        if (body.length > MAX_MAIL_LENGTH){
                            debug_info += "\n#break: over MAX_MAIL_LENGTH (" + body.length + ")\n";
                        }
                        //rate limitでスレッドが途中までしか取得できていない場合、本文もそこまでで打ち切る
                        if ( (lastGetReplies_ts) && (lastGetReplies_ts < last_msg_ts)){
                            debug_info += "\n#break: lastGetReplies_ts=" + _this.formatTs(lastGetReplies_ts) + "\n";
                        }
                        break_flag = true;
                        return true;
                    }

                    debug_info = "#last msg.ts: " + _this.formatTs(last_msg_ts) + "\n";
                    debug_info += "#previous_ts: " + _this.formatTs(previous_ts) + "\n";
                    debug_info += "#yesterday_last_ts: " + _this.formatTs(yesterday_last_ts) + "\n\n";

                    if (!msg.is_thread){
                        previous_ts = msg.ts;
                    }

                });
                yesterday_last_ts = previous_ts;
                if (break_flag){
                    break;
                }
            }
        }
        if (body.length){
            body = start_info + body + debug_info;
        }
        return body;
    };
    SlackChannelHistoryLogger.prototype.formatDate = function (dt) {
        return Utilities.formatDate(dt, Session.getScriptTimeZone(), 'yyyy-MM-dd');
    };
    SlackChannelHistoryLogger.prototype.formatTs = function (dt) {
        if (!dt){
            return dt;
        } else {
            return dt + ' ' + Utilities.formatDate(new Date(+dt*1000), Session.getScriptTimeZone(), 'yyyy/MM/dd HH:mm:ss');
        }
    };
    SlackChannelHistoryLogger.prototype.loadMessagesBulk = function (tr, options) {
        var _this = this;
        if (options === void 0) {
            options = {};
        }
        var messages = [];
        options['count'] = HISTORY_COUNT_PER_PAGE;
        options['channel'] = tr.id;

        var loadSince = function (oldest) {
            if (oldest) {
                options['oldest'] = oldest;
            }
            // order: recent-to-older
            var apiPath = 'conversations.history';
            var resp = _this.requestSlackAPI(apiPath, options);
            if (resp === false){
                return false;
            }
            messages = resp.messages.concat(messages);
            return resp;
        };
        var resp = loadSince();
        if (resp === false){
            return false;
        }
        var page = 1;
        while (resp.has_more && page <= MAX_HISTORY_PAGINATION) {
            resp = loadSince(resp.messages[0].ts);
            page++;
        }
        // oldest-to-recent
        return messages.reverse();
    };
    SlackChannelHistoryLogger.prototype.unescapeMessageText = function (text) {
        var _this = this;
        return (text || '')
            .replace(/&lt;/g, '<')
            .replace(/&gt;/g, '>')
            .replace(/&quot;/g, '"')
            .replace(/&amp;/g, '&')
            .replace(/<@(.+?)>/g, function ($0, userID) {
                var name = _this.memberNames[userID];
                return name ? "@" + name : $0;
            });
    };
    return SlackChannelHistoryLogger;
})();

他にも…

昔作ったSlackの半年か1年以上前のファイルリスト生成してダウンロードしてからサーバーから削除するPHPスクリプトとかも割と需要ありそうな気がするけど、多用途個人ライブラリに依存してる部分があるのでちょっと改造しないと公開できないだな…。

履歴

  • 2020.06.24 公開
  • 2020.06.29 スレッドが多い時にslack APIのrate limitでエラー終了してしまうのを対策
  • 2020.07.10 groups.*もディスコンと知ったのでconversations.listのtypes=mpimに修正、スレッドが日を跨いだときの処理がおかしかったので修正
  • 2020.07.31 im(DM)も保存できるようにする
  • 2020.11.16 スレッドが伸びたので3日前の取得からやり直したい、みたいなことがよくあるので開始tsをメッセージ文頭に表示するようにした
  • 2021.01.17 プロパティのimport/export機能が杜撰だったので修正

iPhoneで撮影したHEIC型式の画像ファイルのExif情報をExifToolで取得する

最近のiPhoneで撮影した写真

HEICで撮影すると容量が抑えられるのは良いのだけどほぼ独自形式と同じなので利用しづらい。

  • 編集情報が別ファイル(AAE)になったりする。利用方法なし。
  • Windows10(とMac?)以外では閲覧もままならない。(最近Windows10でならIrfanViewでも閲覧・変換できることを知って楽になった。)
  • そこらへんのビューアでExif情報が見られない。iPhoneでは見られるので記録されていることは間違いないのだが…。

条件によってはiPhone側でJPEG変換して渡してくれるのだけど、最初からJPEGでいいのでは…って気もしてきます。

ExifToolがHEIC対応していた

最近MediaInfoばかり注目していてPerlでお世話になったExifToolはすっかり失念していました。

MediaInfoではHEICファイルから意味のない情報の羅列しか取得できないので諦めていました。

ExifToolの知識は2016年で止まっていました(2017年に対応してた)。

PHPでの利用

私はPHPスマホで撮った写真のファイル名をリネームするスクリプトを自作して利用しています。

PHPは標準関数 exif_read_dataExifが読めますが、HEICは非対応とエラーが出て読めません。

ググるPHPでExifToolを扱うライブラリはいくつか見つかりますが、やっぱり2016年頃でメンテされなくなっている模様。

代わりにシェル実行関数でExifToolバイナリを開くことにします。

define('EXIFTOOL_PATH','/path/to/exiftool.exe');
function read_exif($file) {
    $shell = shell_exec(constant('EXIFTOOL_PATH').escapeshellcmd(' -j "'.$file.'"'));
    $m = json_decode($shell,true);
    if (is_array($m)){
        return $m[0];
    }
    return array();
}

ExifToolでは-jオプションを使うとJSONで結果を得られます。 -phpオプションを使うとPHPの配列型式になりますが、逆にどうやって使うんだこれ…。

exif_read_data()で得られる配列との違い

だいたい項目名と値の関係は同じですが、GPS周りの型式が違うので注意しましょう。

exif_read_data()は経緯度が配列で返ってきますが、ExifToolでJSON出力したものは文字列です。

exif_read_data()の場合

[GPSLatitudeRef] => N
[GPSLatitude] => Array
    (
        [0] => 35/1
        [1] => 43/1
        [2] => 4991/100
    )

[GPSLongitudeRef] => E
[GPSLongitude] => Array
    (
        [0] => 139/1
        [1] => 42/1
        [2] => 4597/100
    )

ExifToolのJSONから変換した場合

[GPSLatitude] => 35 deg 43' 49.91" N
[GPSLongitude] => 139 deg 42' 45.97" E
[GPSPosition] => 35 deg 43' 49.91" N, 139 deg 42' 45.97" E

利用し辛っ。

どっちも再利用しづらい…。

ググるとわかりますが度分秒から度への変換は北半球南半球の処理分けもあるのでなかなか面倒です。

ExifTool側は-c "%+.6f"オプションを利用しましょう。

define('EXIFTOOL_PATH','/path/to/exiftool.exe');
function read_exif($file) {
    $shell = shell_exec(constant('EXIFTOOL_PATH').escapeshellcmd(' -j -c "%+.6f" "'.$file.'"'));
    $m = json_decode($shell,true);
    if (is_array($m)){
        return $m[0];
    }
    return array();
}
[GPSLatitude] => +35.730531
[GPSLongitude] => +139.712769
[GPSPosition] => +35.730531, +139.712769

そのままGoogle等に投げられる型式になりました。(位置情報は池袋西口公園です)

DeepFusion判定

iPhone11Proではいわゆる超解像写真が撮影できます。

ファイル名がIMG_????S.HEICとなっているのでファイル名でも判別できますが、

"Megapixels": 23.9,

標準は12.2Mピクセルなので、13以上などで判定できそうです。クロップされていなければ。

HEIC以外にも使える

上のGPSの書式変換もそうですが、ExifToolを使うと360度写真の判定なんかもできるようになります。

手持ちのInsta360 ONEで撮影した写真(iPhone用アプリで変換しアルバムに出力したもの)には下記のようなExifがくっついてました。

"ProjectionType": "equirectangular",
"UsePanoramaViewer": true,
"PoseHeadingDegrees": 0,
"PosePitchDegrees": 0,
"PoseRollDegrees": 0,

同じInsta360 ONEで撮影した写真でもスナップショットを切り出した写真には入ってこないのでこれで判定できます。

これは拡張子は普通のJPGですが、PHPexif_read_data()では取得できません。

とはいえ

シェル拡張で使うとセキュリティとかパフォーマンスとか気になるので、PHPの標準関数で扱えるようになってほしいですね。