ふぁメモ

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

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日以上前のスレッドについた返信は追えない。

Googleドライブからコピーできるようにしたかったけどなんかうまくいかない。 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 = [];
        for (var k in params) {
            qparams.push(encodeURIComponent(k) + "=" + encodeURIComponent(params[k]));
        }
        url += qparams.join('&');
        Logger.log("==> GET " + url);
        var headers = {
            'Authorization': 'Bearer ' + API_TOKEN
        };
        var options = {
            'method': 'GET',
            'headers': headers
        };
        try {
            var resp = UrlFetchApp.fetch(url,options);
        }catch(e){
            Logger.log("==> Failed");
            return false;
        }
        if (resp.getContentText().length == 0){
            Utilities.sleep(2 * 1000);
            try {
                var resp = UrlFetchApp.fetch(url,options);
            }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機能が杜撰だったので修正
  • 2021.05.20 アクセストークンをtoken=~のクエリパラメータにしているとinvalid_authが出るようになったのでヘッダに埋め込むよう変更

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の標準関数で扱えるようになってほしいですね。

iOSのショートカットあれこれ

前置き

ずっとFirefox派だったんですが、アドオンがWebExtensionのみになって使い物にならなくなったのでWaterfoxに乗り換えてたんですが、最近重いんですよ。

アドオンいっぱい入れてるからってのもあるけど、世の中のWebコンテンツがChromeに最適化されてきている感じがしています。特にGoogleのサービスはGoogle Chromeじゃないとクソ重い。Googleフォトとか。

そんなわけでついに諦めてChromeに乗り換えつつある今日この頃、一応Kinzaを愛用していたのですが、KinzaはKinzaでYoutubeが再生できないとかTwitterの動画が再生できないとかUI設定が同期もエクスポートもできなくて複数PCで同じ設定にするのが面倒とかいろいろ問題があり、やっぱり純正Chromeしかないのかなっていうところです。

それはさておき

Firefoxで一番愛用していたのはJavaScript Actions (JSActions) であります。

※WebExtension版の JSActions K ではありません。

これで構築した拡張ブックマークレットのようなものは数知れず。Chromeでも活かしたい。

代わりになるものそれはUserScripts、かつてはGreaseMonkey、今はTamperMonkeyであります。

頑張ってJSActionsのスクリプトをTamperMonkeyに移植中です。

一番の違いだと思っていたメニューから選択して実行というUIも、@run-atcontext-menu にしてやれば同じになるんです。たまになぜか表示されないことがありますけど。 ブックマークレットだと難しいクリップボード系もGMなら使えます。

ブックマークレットだと難しいCORS (Cross-Origin Resource Sharing)もGMなら回避できます(今月決まったっぽい方針でダメになるかもしれないけど)。

GM推進派に俺はなるんだ(今更)。

ChromeでUserScriptsに慣れると、モバイルブラウザでも使いたくなりますね。

まあiOSではムリですね。でもAndroidならブラウザ拡張が入れられます。

Firefox Beta、Kiwi Browser、Yandex BrowserなどなどChrome用アドオンがそのまま使えるブラウザアプリがいくつかあります。

でもTamperMonkeyは動くんですがcontext-menuはダメでした。残念。

かといってコンテンツにリンクやボタン浮かしたくないなあ…。諦め。

おっと、

ムリだと思ったiOSですが、あるにはありました。

このアプリは頑張ればブックマークレットを簡単に作れます。ただUIが微妙。CORSの制限もあります。

前にも使ったOS標準になったWorkflow、これも実はイケることがわかりました。でも文法が独特です。よくみんなこれ使いこなしてるな…。

ツリーの直下だとそのまま繋がるんだけどちょっと離れると変数に入れておかないと…あやしく…ええいスマホでプログラミングとかやってられるかっ とまあ使い辛い。

でもなんか頑張ってます。

前置きその2

最近こういうアプリで分割したスクリーンショットを縦長画像に変換したりするのですが、あまり長すぎるとGoogleフォトが取り込んでくれないんです。

今度は逆に分割したくなります。

それで分割アプリを探すんですが、どうも横分割ばかりなんですよね。

どうやらInstagramで需要があるらしい…。

縦分割に対応してるのは有料、それも月額課金のアプリだけか…?みたいな。

そんなわけでショートカットで頑張ってみました。

成果物

f:id:fashi:20200318030008j:plain
CODE

f:id:fashi:20200318030040p:plain
動作中画面

なんというか、画像でしか紹介できないというのがもどかしいですね…。

一応シェア機能はありますので手持ちのiOS機器にインストールすることはできます(先に設定のショートカットの「信頼されていないショートカットを許可」をオンにする必要があります)。

分割数か分割ピクセル数を指定して分割できます。

写真を分割した場合にExifが受け継がれるのが地味にありがたいです。

同じ要領で変数を入れ替えて横分割も作れました。

Instagram用のアプリは正方形になるように頑張ってくれたり投稿までしてくれたり縦横分割したりフレーム付けたりするみたいですが、目的が違うので単純分割するだけです。

しかし

いざやってみたら落ちます。ひどい。画像が大きいと処理できないみたいです。意味ねぇ!!

諦めて複製→トリミングを手動でやるしかなくなりましたが、せっかく作ったので公開しておこうと思った次第であります。

おまけ

はてブするならはてなブックマークアプリがあるのでアプリに渡せばいいだけなんですが、たまにWebページではなくはてなブックマークのエントリーページをシェアしたい時がありますよね。

相手ははてブユーザーではないんだけどこんな話題になっているんだよっていう状態を紹介したい時。

でもはてブアプリでははてなブックマークのエントリーページのURLをシェアできないんですわ。

そんなわけであえてはてブしないでURLをコピーするだけのショートカットを作ってみました。

ついでにメタブのURLも取得できます。

昔ははてブアプリでもメタブができたんですけど、いつの間にかできなくなってしまって、メタブがしたいのに1階しか開けない!ってもどかしいことがよくあったので、メタブのURLを生成できるようにしました。はてブアプリに渡せばブクマできます。

さらにCanonical属性を取得するとモバイルでもデスクトップ用ページが取れたりアクセス解析用パラメータ消したりできるのだけどいちいちWebページとの通信を許可しますかダイアログが出てわりとウザい。

【追記】このショートカットはTwitterをブクマした時のCanonicalは小文字なのになぜか大文字を含んだアカウント名で登録される謎仕様に対応できていません。

ちなみにSafari以外からURLを渡した時にはWebページの内容を取得というのができるのだけどそっちだとJavaScriptは実行できないのが難。

Scriptableというアプリも気になるのでそのうち試そう。