あぼぼノート

頭の中空っぽ

複数のspiderで異なるpipelineを通す

scrapyは使用するpipelineを全て定義する必要があり、普通に書くとどのspiderでも定義した全てのpipelineを通るようになっている。これをそれぞれのspiderで、指定したpipelineだけを通るようにする実装のメモ。

例として、slack_bottweet_botの2つのspiderを定義する。

import scrapy


class SlackBotSpider(scrapy.Spider):
    name = 'slack_bot' # この値を判定につかう

    def parse(self, response):
import scrapy


class TweetBotSpider(scrapy.Spider):
    name = 'tweet_bot' # この値を判定につかう

    def parse(self, response):

slack_bottweet_botそれぞれ専用のpipelineを1つ、共通のpipelineを1つ定義したとすると、以下のように専用のpipelineのprocess_item内で、spider.nameの値をチェックすれば良い。

class SlackPipeline:
    def process_item(self, item, spider):
        if spider.name not in ['slack_bot']:
            return item

        print('SlackPipeline')
        yield item

~~~省略~~~

class TweetPipeline:
    def process_item(self, item, spider):
        if spider.name not in ['twitter_bot']:
            return item

        print('TweetPipeline')
        yield item

~~~省略~~~

class SaveFilePipeline:
    def process_item(self, item, spider):
        print('SaveFilePipeline')
        yield item

settings.pyには全てのpipelineを列挙する必要がある。

ITEM_PIPELINES = {
    'my_crawler.pipelines.SlackPipeline': 300,
    'my_crawler.pipelines.TweetPipeline': 400,
    'my_crawler.pipelines.SaveFilePipeline': 500,
}

これで、slack_bot実行時はSlackPipeline->SaveFilePipeline、tweet_bot実行時はTweetPipeline->SaveFilePipelineを通すことができる(厳密には全てのpipelineのprocess_item()は呼ばれるので、処理をスキップすると言った方が正しい)。

$ scrapy crawl slack_bot
SlackPipeline
SaveFilePipeline

$ scrapy crawl tweet_bot
TweetPipeline
SaveFilePipeline

参考:https://groups.google.com/d/msg/scrapy-users/msKQ7UaYh_E/ee8WSMPRpq0J

Pythonで25時のような表記をdatetimeに変換する

そのままdatetimeにしようとするとエラーが発生する。

import datetime


dt_str = '2020/06/07 25:05'
d = datetime
    .datetime
  .strptime(dt_str, '%Y/%m/%d %H:%M')

# ValueError: time data '2020/06/07 25:05' does not match format '%Y/%m/%d %H:%M'

なので、日付部分をdatetime、時刻部分をtimedeltaとして生成し、両者を足せばOK。

import datetime


dt_str = '2020/06/07 25:05'
d_str = dt_str[:10]  # 2020/06/07
t_str = dt_str[11:]  # 25:05
hour = float(t_str[:2])  # 25
minute = float(t_str[3:])  # 05

d = datetime
  .datetime
    .strptime(d_str, '%Y/%m/%d')  # 2020-06-07 00:00:00

delta = datetime
    .timedelta(hours=hour, minutes=minute)  # 1 day, 1:05:00

dt = d + delta  # 2020-06-08 01:05:00

Python3.7

PythonでTwitter自動投稿botをつくるときに調べたもの

ウェブサイトを定期的にスクレイピングし、自然言語処理をしてその結果をもとにTwitterに投稿するbotをつくった。その際に必要だった技術、調べたもののメモ。

クローラー

scrapy

Pythonクローラーがつくれるフレームワーク。大変お世話になりました。

scrapy-doc-ja.readthedocs.io

qiita.com

今回でいうと自然言語処理をするパイプラインとTwitterに投稿するパイプラインの2つを実装した感じ。

cssセレクタ一覧

scrapy-doc-ja.readthedocs.io

相対パス絶対パスに変換

import scrapy

class MySpider(scrapy.Spider):
    def parse(self, response):
        relative = response.css('a::attr(href)').extract_first()
        absolute = response.urljoin(relative)

ref https://docs.scrapy.org/en/latest/topics/request-response.html#scrapy.http.Response.urljoin

scrapyのロギング最低レベル変更

settings.pyに追記

LOG_LEVEL = "INFO"

ref https://doc-ja-scrapy.readthedocs.io/ja/latest/topics/settings.html#std:setting-LOG_LEVEL

Python言語系

文字列を○文字まで切り取る

# 'あいうえお'
five_str = 'あいうえおかきくけこ'[:5]

文字列をdatetimeに変換

import datetime

date_str = '2020.06.01 12:00'
date = datetime.datetime.strptime(date_str, '%Y.%m.%d %H:%M')

ref Pythonで文字列 <-> 日付(date, datetime) の変換 - Qiita

JST現在時刻をdatetimeで取得

import datetime

dt_jst = datetime.datetime.now(
    datetime.datetime.timezone(datetime.datetime.timedelta(hours=9))
)

ref Pythonで現在時刻・日付・日時を取得 | note.nkmk.me

datetimeの差を求める

タイムゾーンを考慮したdatetimeと、考慮していないdatetimeは計算できない。

import datetime

dt = datetime.datetime.now()
dt1_jst = datetime.datetime.now(
    datetime.datetime.timezone(datetime.timedelta(hours=9))
)
dt2_jst = datetime.datetime.now(
    datetime.datetime.timezone(datetime.timedelta(hours=9))
)


# TypeError: can't subtract offset-naive and offset-aware datetimes
diff = dt - dt1_jst

# OK
diff = dt1_jst - dt2_jst
# diffはtimedeltaオブジェクト
diff.total_seconds() # 0.0

ref python - Can't subtract offset-naive and offset-aware datetimes - Stack Overflow

ref datetime --- 基本的な日付型および時間型 — Python 3.8.3 ドキュメント

docstring

Googleが公開しているdocstringガイドか、numpyというのが主流らしい。

ref styleguide | Style guides for Google-originated open-source projects

ref numpydoc docstring guide — numpydoc v1.1.dev0 Manual

ref [Python]可読性を上げるための、docstringの書き方を学ぶ(NumPyスタイル) - Qiita

カレントディレクトリの絶対パスを取得

import os

path = os.getcwd()

ref os --- 雑多なオペレーティングシステムインタフェース — Python 3.8.3 ドキュメント

自然言語処理

janome.Tokeniserでユーザー辞書を使う

janomeは簡略辞書というフォーマットを使えたため、比較的簡単にユーザー辞書を追加することができました。

from janome.tokenizer import Tokenizer

# uidc.csvの中身の例↓
# あつ森,カスタム名詞,アツモリ
# とたけけ,カスタム名詞,トタケケ
t = Tokenizer(
    udic='udic.csv',
    udic_type='simpledic'
)
t.tokenize(text)

ref Welcome to janome's documentation! (Japanese) — Janome v0.3 documentation (ja)

特定品詞のみ抽出

from janome.tokenizer import Tokenizer

for token in t.tokenize(text):
    if (token.part_of_speech.startswith('名詞,固有名詞')
        or token.part_of_speech.startswith('名詞,一般')
        or token.part_of_speech.startswith('形容詞,自立')
        or token.part_of_speech.startswith('カスタム名詞')):
            print(token.surface)

Twitter API

Twitter API 利用申請手順

全て英語で書かなければならず、さらに○○文字以上の制約がある項目もあり少々面倒。DeepL翻訳にお世話になりました。

www.deepl.com

ref 2020年度版 Twitter API利用申請の例文からAPIキーの取得まで詳しく解説 | 新宿のホームページ制作会社 ITTI(イッティ)

PythonTwitter APIクライアント

いろいろ見つかったが今回はこれを使用。

ref https://pypi.org/project/twitter/

Heroku連携系

秘匿情報をHerokuのConfig Varsから注入

Twitter APIの認証情報などはソースコードにベタ書きしたくないため、Herokuの機構をつかって環境変数にいれます。Pythonから環境変数を取得する方法は下記のとおり。

import os

os.environ.get('SECRET_VALUE', 'default value')

ref https://devcenter.heroku.com/articles/getting-started-with-python#define-config-vars

Heroku Schedulerで定期実行

Herokuのアドオンで追加できる。Dyno Sizeが無料だと10分ごと、○時間ごと、○日ごと、の3パターンが使える。

ref https://devcenter.heroku.com/articles/scheduler

Flutterで自作パッケージをpub.devに公開する

基本的にDeveloping packages & pluginsを参考に進めればOK。

パッケージを生成する

テンプレートには二種類ある。今回はDart packagesとして作成するので以下のコマンドで生成。

flutter create --template=package {package_name}

生成されたら、lib/{package_name}.dartを編集する。

サンプルプロジェクトを追加する

--template=packageで生成したプロジェクトにはtestしか含まれてないので、必要に応じてサンプルプロジェクトを追加する。

パッケージのカレントディレクトリで以下のコマンドでexampleというプロジェクトを生成する。

# in {package_name}
flutter create example

生成されたら、example/pubspec.yamlのdependenciesに自作パッケージを追加。

dependencies:
  flutter:
    sdk: flutter

  {package_name}:
    path: ../

その後example/main.dartに自作パッケージのサンプル実装を記述する。

README.md, CHANGELOG.md , pubspec.yaml, LICENSEの編集

pub.devに公開するにあたってこれらのファイルを正しく編集する。

基本的にそれぞれデフォルトで例が書かれてあるので参考に。LICENSEは他のパッケージを参考に。

CHANGELOG.md

## [0.0.1] - 2019-11-04

* first release

pub.devに公開

以下のコマンドでpub.devにpublishできる。

flutter packages pub publish

途中認証のためにURLをひらく必要がある。認証に成功すればこんな感じ。

f:id:aboy_perry:20191104165801p:plain
pub.dev認証完了

認証完了後、pub.devにアップロードが成功すると晴れて自作パッケージが公開される。

pub.dev

Flutterで1文字目だけスタイルを変える

Text.richTextSpanを使う。TextSpanで設定したstyleはchildrenに引き継がれるので、childrenのTextSpanで上書きする。

final String text = "Flutter";
Text.rich(
    TextSpan(
        text: text.substring(0, 1),
        style: TextStyle(
            fontSize: 36,
            color: Colors.blue,
            letterSpacing: 1,
        ),
        children: <TextSpan>[
            TextSpan(
                text: text.substring(1),
                style: TextStyle(
                    fontSize: 18,
                    color: Colors.black87,
                ),
            ),
        ],
    ),
),

f:id:aboy_perry:20191103183918p:plain:w300
Text.richを使って1文字目のTextStyleを変える

Flutter for Webで404ページを表示させる

MaterialAppのonGenerateRouteプロパティを使うとURL直打ちでも指定のページを表示させられる。同様に未定義のURLが指定された場合に呼ばれるonUnknownRouteを使えばOK。

class MyApp extends StatelessWidget {
  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      onGenerateRoute: (settings) {
        switch (settings.name) {
          case '/':
            return MaterialPageRoute(
              builder: (context) => MyHomePage(),
            );
        }
      },
      initialRoute: '/',
      onUnknownRoute: (RouteSettings settings) {
        return MaterialPageRoute(
          builder: (context) => MyNotFoundPage(),
        );
      },
    );
  }
}

ルーティングの処理順序はこのへんを参照。

# flutter/lib/src/material/app.dart#L57
/// The [MaterialApp] configures the top-level [Navigator] to search for routes
/// in the following order:
///
///  1. For the `/` route, the [home] property, if non-null, is used.
///
///  2. Otherwise, the [routes] table is used, if it has an entry for the route.
///
///  3. Otherwise, [onGenerateRoute] is called, if provided. It should return a
///     non-null value for any _valid_ route not handled by [home] and [routes].
///
///  4. Finally if all else fails [onUnknownRoute] is called.

FlutterでAppBarを透過する

AppBarのbackgroundColorを透明にしただけだとAppBarは透明にならない。

ScaffoldのextendBodyBehindAppBarプロパティにtrueを設定すればOK。

class _MyHomePageState extends State<MyHomePage> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
        backgroundColor: Colors.white.withOpacity(0.5),
      ),
      extendBodyBehindAppBar: true,
      body: ListView.builder(
        padding: const EdgeInsets.all(24),
        itemCount: 1,
        itemBuilder: (BuildContext context, int index) {
          return Container(
            height: 1000,
            color: Colors.blue,
          );
        },
      ),
    );
  }
}

f:id:aboy_perry:20191027011036p:plainf:id:aboy_perry:20191027005311p:plain
左:extendBodyBehindAppBar: false 右:extendBodyBehindAppBar: true

ちなみに、scaffoldの仕様で、extendBodyBehindAppBarがtrueかfalseかでpaddingのtopが変わる。

f:id:aboy_perry:20191027005430p:plainf:id:aboy_perry:20191027005311p:plain
左:extendBodyBehindAppBar: false 右:extendBodyBehindAppBar: true

// flutter/lib/src/material/scaffold.dart#L366
final double top = extendBodyBehindAppBar
          ? math.max(metrics.padding.top, bodyConstraints.appBarHeight)
          : metrics.padding.top;

https://github.com/flutter/flutter/blob/master/packages/flutter/lib/src/material/scaffold.dart#L366