Scrapyを1ファイルで動かすサンプル

# 概要
[Scrapy](https://doc-ja-scrapy.readthedocs.io/ja/latest/index.html)をstartprojectを使わず１ファイルで記述し、pythonコマンドで実行するサンプルです。

対象サイト( https://blog.qiita.com/ )から各記事のデータを収集してJSON Lines形式でファイル出力します。

**実行コマンド**　終了はCtrl+Cを２回
```sh
$ python main.py
```


# サンプルコード
```python:main.py
# 要インストール
# $ pip install scrapy
import scrapy
from scrapy.crawler import CrawlerProcess
from scrapy.extensions.httpcache import DummyPolicy


class MySpider(scrapy.Spider):
    """スパイダー https://doc-ja-scrapy.readthedocs.io/ja/latest/topics/spiders.html"""

    name = "my_spider"

    # クロールを開始するURL このURLが最初にリクエストされレスポンスはparseメソッドで処理される
    start_urls = ["https://blog.qiita.com/"]

    def parse(self, response):
        """一覧ページ処理"""
        # 詳細ページへのリンクを選択
        for detail_href in response.css('h2.indexArticle_title a::attr("href")'):
            # hrefが相対パスの場合は絶対パスに直す
            detail_url = response.urljoin(detail_href.get())
            # 詳細ページをリクエスト
            yield scrapy.Request(
                # リクエストするURL
                detail_url,
                # レスポンスを処理するコールバック
                self.parse_detail,
            )
        # 次ページがあればリクエストする
        if next_href := response.css('a.nextpostslink::attr("href")').get():
            # Requestオブジェクトを作成するショートカットとしてresponse.followを使用できる
            yield response.follow(
                # リクエストするURL(相対パスでOK)
                next_href,
                # レスポンスを処理するコールバック
                self.parse,
            )

    def parse_detail(self, response):
        """詳細ページ処理"""
        # アイテムクラスの代わりにdictでもOK
        item = dict()
        item["title"] = response.css("h1.article_title::text").get()
        item["date"] = response.css("time.entry-date::text").get()
        item["content"] = response.css("div.article_body").xpath("string()").get()
        item["url"] = response.url

        # これが１レコード分のデータになる
        yield item


class MyItemPipeline:
    """アイテム・パイプライン https://doc-ja-scrapy.readthedocs.io/ja/latest/topics/item-pipeline.html"""

    def process_item(self, item, spider):
        # contentを省略する
        item["content"] = item["content"][:50] + "..."
        return item


# 200ステータスのみキャッシュするポリシー
class CachePolicy(DummyPolicy):
    def should_cache_response(self, response, request):
        return response.status == 200


def get_settings():
    # 設定 https://doc-ja-scrapy.readthedocs.io/ja/latest/topics/settings.html
    settings = {
        # robots.txtポリシーを尊重する
        "ROBOTSTXT_OBEY": True,
        # ダウンロード間隔 デフォルト0
        "DOWNLOAD_DELAY": 1,
        # リクエストヘッダーの言語をjaに設定
        "DEFAULT_REQUEST_HEADERS": {
            "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
            "Accept-Language": "ja",
        },
        # キャッシュを有効にする
        "HTTPCACHE_ENABLED": True,
        # キャッシュの有効期限 0は期限なし
        "HTTPCACHE_EXPIRATION_SECS": 60 * 60 * 24,  # 24h
        # 200ステータスのみキャッシュする
        "HTTPCACHE_POLICY": "__main__.CachePolicy",
        # アイテム・パイプラインを設定する
        "ITEM_PIPELINES": {"__main__.MyItemPipeline": 500},
        # 出力ファイルの設定 https://docs.scrapy.org/en/latest/topics/feed-exports.html#std-setting-FEEDS
        "FEEDS": {
            "output.jsonl": {
                "format": "jsonlines",
                "encoding": "utf8",
                "overwrite": True,
            }
        },
        # ログレベル デフォルト"DEBUG"
        # "LOG_LEVEL": "INFO",
    }
    return settings


if __name__ == "__main__":
    # Scrapy実行
    process = CrawlerProcess(settings=get_settings())
    process.crawl(MySpider)
    process.start()
```


# 実行結果
```json:output.jsonl
{"title": "募集ページをリリースします", "date": "2022/6/7", "content": "こんにちは、Qiita運営です。今回は「募集ページ」のリリースについてお知らせします。 募集ページに...", "url": "https://blog.qiita.com/opportunity-release/"}
{"title": "Qiitadonのサービス提供を終了します", "date": "2022/2/1", "content": "こんにちは、Qiita運営です。今日は「Qiitadon」の提供終了についてお知らせします。2017...", "url": "https://blog.qiita.com/terminate-provision-of-qiitadon/"}
{"title": "QiitaのMarkdownパーサーの変更をベータ版として公開します", "date": "2022/2/8", "content": " こんにちは、Qiita運営です。 今日はQiitaのMarkdownパーサーの変更をベータ版として...", "url": "https://blog.qiita.com/replace-markdown-parser-beta/"}
{"title": "QiitaのMarkdownパーサーの変更を正式にリリースします", "date": "2022/2/21", "content": " こんにちは、Qiita運営です。今日はベータ版として公開していたMarkdownパーサーの変更を正...", "url": "https://blog.qiita.com/replace-markdown-parser-general/"}
{"title": "Alexaスキル – Qiita 人気の投稿 の提供を終了します", "date": "2022/3/1", "content": "こんにちは、Qiita運営です。今日は「Alexaスキル – Qiita 人気の投稿」の提供終了につ...", "url": "https://blog.qiita.com/terminate-provision-of-qiita-trend-available-in-alexa-skill/"}
{"title": "記事カードのUI変更をベータ版として公開します", "date": "2022/3/18", "content": "こんにちは、Qiita運営です。今日は、Qiitaの記事カードのUI変更についてお知らせいたします。...", "url": "https://blog.qiita.com/update-article-card-beta/"}
{"title": "期間限定の記事投稿キャンペーン「Qiita Engineer Festa 2022」を6〜7月に開催決定！", "date": "2022/3/25", "content": "こんにちは、Qiita運営スタッフです！ 2022年6月1日（水）より「Qiita Engineer...", "url": "https://blog.qiita.com/qiita-engineer-festa-2022-sponsor/"}
...

```

.scrapyディレクトリ以下にページのキャッシュが保存されます。
```sh
$ tree .scrapy 
.scrapy
└── httpcache
    └── my_spider
        ├── 02
        ...
```