ふぁメモ

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

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というアプリも気になるのでそのうち試そう。

スマホ間でURLや画像の受け渡し (未完成)

■ 序

すっごくよくある話なんだけど、iOSAirDropみたいな感覚でURLや画像をiOS以外の端末に送りたい時~

よくある話なのでいろんなアプリがあったのだけど、最初は便利だったシンプルなアプリが、なぜか時が経つに連れて広告が入ったり有料化したり会員登録が必須になったり新しいOSで動かなくなったり…

てのもよくある話。

ちょっとした使いづらさは諦めて、LINEで共有したり(複アカめんどい)、Pushbulletで共有したり(割と鬱陶しいアプリになった)、Zeetleで共有したり(かなり鬱陶しいアプリになった)、Chromeのタブ履歴使ったり(まずChromeに渡すのがめんどい)、とりあえずはてブしたりするわけだ。

そんな折に

この記事。Piping-Server。これ。これ面白そう。これ使えそう。そんな感じがしてきたのでなんかうまく出来ないか試行錯誤する記録である。

■ Piping-Serverをかんたん共有アプリ化として使うにあたっての問題点

フォームがしょぼい

何もパスを指定しないと簡易的なフォームが出る。画像やテキストがすぐ送れる。ちゃんと考えられてる。便利。 なのだが、ここをもうちょっと使いやすくしたい。

パスを記憶させておくとか、パラメータで渡せるようにとか、モバイルフレンドリーなUIにするとか、PCではD&Dで登録できるようにとかしたい。

受信がしょぼい

元々パイプでデータを受け渡すためのものなので当然といえば当然だが、生データがペロッと出力されるのみである。

画像や動画などはこれでいい。Content-Typeが引き継がれる仕様なので送信側でヘッダを工夫すれば如何様にもなる。

しかしURLなどのテキストはいかんともしがたい。

中身がtext/plainのときはもうちょっとこう、HTMLで出してコピーしやすくしたりクリッカブルにしたりはてブ表示したりメタブクマしたりしたい。

ついでにパラメータやCookieでパスを覚えておいてもらったりそれに応じて受け取りボタンとかつけたい。

しかしながら

中身はJSなので改造は比較的容易。しかし改造するとバージョンアップが面倒。なによりdockerで簡単導入できなくなる。

自己都合な使い方をプルリクするのもどうかと思う。

そんなわけで

ラッパーを作りたい感じ。シェルコマンド叩くだけならPHPとかで簡単にできそう。たぶん。 ラッパー作ったらデータ送信用に乱数パス生成してデータ送る前にパス送るみたいななんちゃってセキュリティも出来るだろうし…

■ 共有方法

ラッパーを作る前にとりあえず素のままで頑張ってみようの巻。

iOSから送る場合(iOS12以降)

「ショートカット」アプリ一択。

 
 

こんな感じでショートカットを作る。

全部まとめてもいいのだがテキストとメディアファイルは分けたほうが無難。

あと「URL」を受け取るようにするとなぜかHTMLの中身が送られてしまうので注意。 「テキスト」型式のみにする。

ちなみに

この設定で「共有シートに表示」がオンになっていればファイル欄に「ショートカットの入力」が選べるようになります。

これでブラウザからでも写真アプリからでも共有でファイルが送信出来る。

iOSから送る場合(iOS12以前)

これが困った。

宗教上の理由(※32bitアプリを使い続けたい等)でiOS12以前の場合は「ショートカット」アプリは使えません。

「ifttt」でいいかなと思ったら画像は送れないしテキスト(Note widget)は文字化け(たぶんContent-Typeがおかしいのでラッパー使えば直せる案件かも)。

HTTP POST/PUT出来るアプリはたくさんあるのだけど共有シートから受け取れるヤツがない。

共有はあきらめて大人しくクリップボード経由でWebフォームから送るほうがラクかなあ…。

Androidから送る場合

Automateあたりで出来るんじゃなかろうかと思ったが試行錯誤して疲れたので諦めた。 Taskerは持ってるけど有料だから次はmacrodroidとかで試すかな→Getしかできなかった

以下未稿

リモートデスクトップアプリの私的メモ

リモートデスクトップ

  • MS純正なので強い・速い・快適
  • 最近はどうだか知らないがスマートカードバイスでトラブる印象が残ってて敬遠
  • 最近はどうだか知らないが別途VPNトンネルが必要
  • Homeエディションでは接続不可
  • iOS/Androidクライアントがアップデート直前の期間だけダウンロードできなくなる

Desktop VPN

  • 無料の時代は便利に使ってた
  • 有料化したので使わなくなったが、有料前提で考えてもPCからしか使えないので今となっては魅力薄い。

VNC (UltraVNC)

  • 無料で暗号化やらリピータ(NAT間接続)やら対応してて高速化もすごい頑張ってたので昔は愛用してた
  • 昔はμVNCとか日立が頑張ってて携帯からPC操作とか便利すぎた
  • よく攻撃対象にされるので怖い。外部にポート解放しようものなら接続失敗ログが大量に出る。プラグインの暗号化だけじゃなくVPNトンネルも使いたいところ。昔はZebedee使ってた。
  • iOSAndroidでもSSHトンネル経由で接続できる、わかってるクライアントが多いのも助かる(iTeleportとか)
  • Windows8あたりからうまく対応できなくて重く微妙になっていく

CrazyRemote Pro

  • iOSからPC/Mac専用だが、かなり快適に使えた。特に日本語対応が完璧で違和感なくIMEが使える。
  • 今は更新が途絶えている

Brynhildr

  • 個人レベルの割にすごい頑張ってる。アプリの名前がよく変わる。
  • すごい速い代わりにかなりCPUやネットワークを消費されるので万能感はない。LAN内では便利だが外から使う気にはなれない。
  • ポリシー的にどうのでAlt+Tabするとリモートじゃなくホスト側で動いてしまう系のフックが使えないのが不便
  • 実はゲームパッドに対応してたりする凄い奴なのだが、ドキュメントが整備されていないので見つけるのに苦労すると思う
  • iOS版もある上によく無料になるのだが画面構成がオサレすぎて使いにくい
  • NAT間で使えるリピーターも公開されているのだが不安定すぎて使えない

Chromeリモートデスクトップ

  • ブラウザのアドオンなのにここまで出来るって逆に怖い(今はChromeOSとかあるから少し薄れたが)
  • よく本体がバージョンアップする&本体がメモリ食いなため、いつでも使える安心感はない。非常用に入れておいてもいいかなという感じ。

AnyDesk

  • アプリの行儀が悪いので敬遠。

LogMeIn

  • それまで使ってきたすべてのリモートデスクトップアプリを凌駕する性能と便利機能の数々で、セキュリティも高く愛用していたが、方針変更で無料版がなくなってしまった
  • まあこんだけ便利なら有料でもいいよと思って金を払って使っていたが、事前告知なしで価格が倍々に跳ね上がることが数回あり、問答無用で引き落とされるのに嫌気が差し解約
  • 解約するのも日本語が拙いサポートとメールでやりとりせねばならずかなり面倒だった

TeamViewer

  • LogMeInほどではないが、そこそこの性能で無料で使えて便利。VPNやリモート印刷にも対応。デスクトップだけならD&Dファイル転送にも対応。完全にオマケだがビデオ会議などもある。
  • 最近はAndroidスマホのリモート操作(PCやiPhoneからAndroidを操作)にも対応している。これも便利。ただしFireHDは不可。
  • 無料で使っていると非商用でのみ無料ですと毎回ダイアログが出る。仕方ないことだよねと思うが結構ウザい。広告消せるなら金を払ってもいいと思うが個人用のプランはない(業務用は高すぎる)。
  • しかししばらく使っているとどういうわけか「お前商用環境で使っているだろ」といった嫌疑をかけられる。無視して使っていると5分で自動切断されその後10分繋がらなくなる。サポートに家庭でしか使ってない旨を伝えると解除してもらえるが、返信すらない上にしばらくするとまた嫌疑をかけられるのが結構うざったい。
  • iOS版は一度設定すると指紋認証とか何もなくてパスワード不要で繋がってしまうのでもう少しセキュリティ面を考えてもらいたい。

Splashtop

  • LAN内のみ無料、外からの接続は有料だがそこそこリーズナブル。いろいろ便利になる業務用プランでもリーズナブルなので業務用プランを契約することにした。
  • iPhoneアプリからは押せないキーがある。地味に不便。だがiPadではバーチャルパッド機能が使えてめちゃくちゃ快適。iPhoneでも使えるようにしてくれ。
  • 接続直後は画面がモザイク。地味に不便。
  • サーバーログを全画面で流したりおおきく画面が更新されると切断されることがある。地味に不便。
  • 時間帯によっては画面更新が遅くなる。地味に不便。
  • たまにサーバーアプリが固まる。地味に不便。
  • リモートでGoogleハングアウトやChromeリモートデスクトップを使うと画面が真っ暗になったりする。謎。
  • PCによってはメニューの内側が真っ白になったりスクロールしても描画されなかったりする。謎。

総括

SplashtopのBusinessプランを利用しつつ、うまく繋がらない時だけTeamViewerで接続して再起動。今これ。

結局Windows10HomeでWindowsUpdateによる強制再起動を防ぐ方法はないのか…

ググってわかったのは

  • 通常の設定変更で可能な再起動のリスケ(7日後まで設定可能)で解決すると思い込んでいるサイト
  • Insider Previewの時に出来ていた方法をそのまま紹介しているサイト
  • Windows10Proでしか出来ない方法を紹介しているサイト(Homeでは試していない、みたいなのはまだいいほうで、Homeでもグループポリシーエディタ使う代わりにレジストリ弄れば出来ると思う、みたいなサイトもあるが、Homeにはそもそも該当箇所のレジストリツリーがない)
  • Windows7/8からWindows10へのアップグレードの話とごっちゃになっているサイト

ばかりで、結局Homeでは不可能ということみたいな。
VMwareとかでなんか入れてるとか、何かしらサーバー系のソフト常駐させてるとか、作業途中で保存も中途半端にデスクトップを放置することがある人はHomeは選んじゃダメでProにしろってこったな。

普通にまとまってるのは
Windows 10 の自動更新を無効(Windows Updateを手動更新のみ)にする方法 - ぼくんちのTV 別館
ここと2chスレくらいか
Windows 10 の強制 Windows Update を制御するスレ

あとマイクロソフトコミュニティのスレッドがちょっと面白かったというか
Windows10において、Windows Update後、勝手に再起動される - マイクロソフト コミュニティ

消えた作業分どうしてくれますの?


追記:
最終手段としてWindowsUpdateのサービスを無効にすれば強制再起動は防げる。ただしアップデートにも気付けない。