使用Docker部署scrapy-redis分布式爬蟲

引言

在上篇使用Scrapy爬取知乎用戶信息我們編寫了一個單機的爬蟲,這篇記錄了使用Scrapy-Redis將其重寫,使其具備分布式抓取的能力,并使用Docker部署到我們兩臺云server

為什么要分布式,顯然單機的爬蟲無論在機器的帶寬還是ip等在爬取的時候都會有一定的限制,為了提高我們爬取的效率,我們需要編寫一個可以在多臺機器上同時運行的爬蟲,在其爬取狀態同步的同時,對我們想要的信息進行爬取。而Scrapy-Redisgithub上的一個開源項目。

為什么使用Docker,說到Docker可能很多人老早就聽說過其在江湖上的傳說,在我寫這篇文章之前,也僅僅是對其有個模糊的了解,但是我昨天晚上在部署我的Project到我的兩臺服務器上的時候,我被基礎環境的配置給弄瘋了,之前我在阿里云服務器上安裝了Py3,勉強可以支撐我之前的需求。

但是昨晚在安裝Scrapy的時候,為了裝一個Package,我幾乎要把Py3、OpenSSL等組件重新安裝一遍,在痛苦的掙扎之后,我想到了Docker(也許是瞄到了官方的例子中的Dockerfile),果不其然簡單的google下就看到了大量的部署Scrapy的案例。

簡單的看了下官方文檔,沒一會就將我的項目部署到了兩臺服務器上并正常運行,這太驚艷了!

原理

分布式爬蟲原理

Scrapy-Redis其僅僅是一雙飛翔的翅膀,它自身并沒有爬蟲能力,它僅僅是為Scrapy這個框架披上了一層鎧甲。所以閱讀本小節需要對Scrapy的工作原理有一定的了解,如不感興趣可以跳過

試想如果我們自行編寫一個分布式爬蟲在多臺主機上運行,則我們需要將爬蟲的爬取隊列進行共享,也就是說每一臺主機都需要訪問到一個共享的隊列,然后我們的爬蟲就是從隊列中取一個request進行爬取,當然這些Scrapy-Redis都已經幫我們做好了,他可能需要做的是如下操作:

  • 初始化爬蟲,創建一個Redis的客戶端,連接上Redis
  • 查看請求隊列是否為空,如果是空則等待,當請求的隊列不為空,則從請求隊列中拿出一個request
  • 獲得request,經過scheduler調度后,engine會將request取出,送給downloader進行請求
  • 經過請求后,返回給EngineEngine將結果返回給用戶寫的爬蟲,對結果進行處理,可能出現下一個request,也可能是item
  • 如果請求后得到的是一個request,則會通過scheduler再次調度,判斷request是否重復,并將request放入請求隊列
  • 如果經過得到了item,則Scrapy會將item交給我們的pipeline處理

可見,scrapy_redis就是將request調度的隊列、請求的隊列和獲取的item放在了一個多臺主機可以同時訪問的redis的數據結構里

Scrapy-Redis源碼分析

所謂請求分布式,就是使用redisrequest抓取隊列和url去重集合進行管理,從而達到爬蟲的狀態同步。

以下就包含了Scrapy-Redis的源碼結構,也就只有這么幾個文件:

$ tree ScrapyRedisZhihu/scrapy_redis/
ScrapyRedisZhihu/scrapy_redis/
├── connection.py
├── defaults.py
├── dupefilter.py
├── __init__.py
├── picklecompat.py
├── pipelines.py
├── queue.py
├── scheduler.py
├── spiders.py
└── utils.py

0 directories, 10 files

初始化爬蟲,創建一個Redis的客戶端,連接上Redis,查看SPIDER_NAME:start_urls請求隊列是否為空,如果是空則等待,當請求的隊列不為空,則從請求隊列中拿出一個request

首先connection.pydefaults.py僅僅根據我們的setting.py配置文件,為爬蟲提供一個reids client的實例。

而對于不同的Spider對上層都暴露出一個創建爬蟲的接口,上層這個接口就是crawler_create_spider方法,調用這個方法干了一件事情:調用不同spiderfrom_crawler這個方法,無論我們是Spider還是RedisSpider。在不同的spiderfrom_crawler函數都會重寫,原生Scrapy提供的from_crawler會創建出不同類型的爬蟲并將其返回給Crawler,而我們一般都不會去重寫這個method。而這個from_crawler會創建出不同類型的爬蟲如zhihu_spider、lajou_spider等并將其返回給上層。

由于我們的爬蟲是繼承了RedisSpider這個類,RedisSpider繼承了Scrapy原生的Spider類,RedisSpider主要重寫了Spiderfrom_crawler類方法,Spiderfrom_crawler類方法起到了一個Constructor的作用,解耦了spider實例化的具體實現和上層調用。

class RedisSpider(RedisMixin, Spider):
    @classmethod
    def from_crawler(self, crawler, *args, **kwargs):
        obj = super(RedisSpider, self).from_crawler(crawler, *args, **kwargs)
        obj.setup_redis(crawler)  # RedisMixin中的setup_redis
        return obj

并注意,RedisSpider它采用了Python的多繼承Mixin設計,他不僅繼承了Spider還繼承了RedisMixin,當我們RedisSpider初始化,它和原生Scrapy不同的是:調用setup_redis來初始化和redis的相關設置

class RedisMixin(object):
    def setup_redis(self, crawler=None):
        """Setup redis connection and idle signal."""
        self.server = connection.from_settings(crawler.settings)
        # The idle signal is called when the spider has no requests left,
        crawler.signals.connect(self.spider_idle, signal=signals.spider_idle)

setup_redis也只是做了兩件事,根據我們的setting.py配置文件,初始化一個redis連接;和為爬蟲設置一個信號,當spider空閑,會調用spider_idle這個方法,spider_idle則會將調用schedule_next_requests,而schedule_next_requests也只是調用next_requests如下:

class RedisMixin(object):
    def spider_idle(self):
        """Schedules a request if available, otherwise waits."""
        self.schedule_next_requests()
        raise DontCloseSpider   

    def schedule_next_requests(self):
        """Schedules a request if available"""
        for req in self.next_requests():
            self.crawler.engine.crawl(req, spider=self)

    def next_requests(self):
        """Returns a request to be scheduled or none."""
        use_set = self.settings.getbool('REDIS_START_URLS_AS_SET', defaults.START_URLS_AS_SET)
        fetch_one = self.server.spop if use_set else self.server.lpop
        found = 0
        while found < self.redis_batch_size:
            data = fetch_one(self.redis_key)
            if not data:
                # Queue empty.
                break
            req = self.make_request_from_data(data)
            if req:
                yield req
                found += 1

      def make_request_from_data(self, data):
          url = bytes_to_str(data, self.redis_encoding)
          return self.make_requests_from_url(url)

由上我們可以知道當spider空閑的時候,會嘗試在redis_key這個數據結構中嘗試獲得一個url,這個數據結構可以是默認的list也可以設置為set,設置為set則需要用REDIS_START_URLS_AS_SETsetting中進行設置。

如果獲得一個url就進行一些decode處理,并進行請求,這里make_requests_from_url默認就是用的標準Spider類中的make_requests_from_url方法,其僅僅是return Request(url, dont_filter=True)

在如果沒有url就會拋出DontCloseSpider這個異常,在上層engine._spider_idle早已將其捕獲,并會再次重新調用spider_idle。這樣就實現了一直嘗試獲得一個的request,沒有則并進行等待并重新調度。

需要注意的是,在第一次調用spider_idle之前,爬蟲會調用start_requests這個方法,這是由上層crawler中的crawl方法所決定的,這個方法也是Scrapy入口

@defer.inlineCallbacks
def crawl(self, *args, **kwargs):
    assert not self.crawling, "Crawling already taking place"
    self.spider = self._create_spider(*args, **kwargs)
    self.engine = self._create_engine()
    start_requests = iter(self.spider.start_requests())
    yield self.engine.open_spider(self.spider, start_requests)
    yield defer.maybeDeferred(self.engine.start)

在我們創建好spiderengine,就會調用start_requests,在原生的Spider類中這個函數會迭代start_urls這個列表,也就是我們熟悉的爬蟲的入口url,而在RedisMixin中將其重寫,在redis的數據結構中獲得爬蟲的起始頁面。

def start_requests(self):
    return self.next_requests()

所以,我們在使用RedisSpider類進行分布式爬蟲編寫的時候,就不需要定義start_urls這個列表,相反需要增加一個redis_key = 'zhihu_redis:start_urls',并且我們在運行爬蟲的時候,需要手動在redis的數據結構內push/add一個邏輯上的start_url,使其被next_requests方法獲得

start_requests方法被調用,將回到crawl調用engine.open_spider方法,這個方法會初始化scheduler并調用爬蟲中間件spidermw.process_start_requests(start_requests, spider),之后會調用scheduler.open這個方法,這個方法會初始化我們的優先級隊列和過濾器。并注冊engine._next_requestreactor loop里面,當nextcall.schedule()調用,engine._next_request將會被調用

def _next_request(self, spider):
    while not self._needs_backout(spider):
        if not self._next_request_from_scheduler(spider):
            break   #第一次執行,scheduler中沒有request,會直接break

    if slot.start_requests and not self._needs_backout(spider):
        try:
            request = next(slot.start_requests)
        except StopIteration:
            slot.start_requests = None
        except Exception:
            slot.start_requests = None
            logger.error('Error while obtaining start requests',
                         exc_info=True, extra={'spider': spider})
        else:
            self.crawl(request, spider)

    if self.spider_is_idle(spider) and slot.close_if_idle:
        self._spider_idle(spider)   
        #對 RedisSpider 中的DontCloseSpider進行捕獲,捕獲到直接返回,回到`reactor loop`

由于第一次執行程序,redis數據結構中(SPIDER_NAME:start_urls)沒有對應的url,在這個注冊到reactor loop內的方法,調用了_spider_idleRedisSpider中的DontCloseSpider進行捕獲。并直接返回到reactor loop,直到從redis中獲得url,調用crawl

def crawl(self, request, spider):
    assert spider in self.open_spiders, \
        "Spider %r not opened when crawling: %s" % (spider.name, request)
    self.schedule(request, spider)
    self.slot.nextcall.schedule()

def schedule(self, request, spider):
    self.signals.send_catch_log(signal=signals.request_scheduled,
            request=request, spider=spider)
    if not self.slot.scheduler.enqueue_request(request):
        self.signals.send_catch_log(signal=signals.request_dropped,
                                    request=request, spider=spider)

可以看到,在engine.cawl中調用了engine.schedule,這里終于調用了scheduler.enqueue_request,也就是scrapy_redis中的scheduler.py模塊中的enqueue_request

經過scheduler調用dupfilter的判重,會將request送到優先級隊列當中,送給downloader進行請求

scheduler在將request放入SPIDERNAME:requests這個隊列時,關于requests去重,Scrapy-Redis還是使用原來的做法,提取指紋(hashlib.sha1),放入一個集合,但是Scrapy-Redis使用了Redis的集合,這樣多臺主機可以共享一個用于去重的集合

def enqueue_request(self, request):
    if not request.dont_filter and self.df.request_seen(request):
        return False
    self.queue.push(request)
    return True

# dupfilter.py
def request_seen(self, request):
  fp = self.request_fingerprint(request)
  added = self.server.sadd(self.key, fp)
  return added == 0

def next_request(self):
    block_pop_timeout = self.idle_before_close
    request = self.queue.pop(block_pop_timeout)
    if request and self.stats:
        self.stats.inc_value('scheduler/dequeued/redis', spider=self.spider)
    return request

優先級調度隊列scrapy_redis:requestsQueue.py中被定義的模塊,對原生的隊列進行了重寫,使用Redislistzset分別實現了三種隊列,LifoQueueFifoQueuePriorityQueue。默認使用的是PriorityQueue

request進入優先級隊列之后,會調用nextcall.schedule,也就是會調用engine._next_request,當再次調用engine._next_request_from_scheduler,再調用scheduler.next_request就會將優先級隊列中的的url取出。并進行下載了

def _next_request_from_scheduler(self, spider):
    slot = self.slot
    request = slot.scheduler.next_request()
    if not request:
        return
    d = self._download(request, spider)
    d.addBoth(self._handle_downloader_output, request, spider)  #注冊 _downloader 回調
    d.addErrback(lambda f: logger.info('Error while handling downloader output',
                                       exc_info=failure_to_exc_info(f),
                                       extra={'spider': spider}))
    d.addBoth(lambda _: slot.remove_request(request))
    d.addErrback(lambda f: logger.info('Error while removing request from slot',
                                       exc_info=failure_to_exc_info(f),
                                       extra={'spider': spider}))
    d.addBoth(lambda _: slot.nextcall.schedule())
    d.addErrback(lambda f: logger.info('Error while scheduling new request',
                                       exc_info=failure_to_exc_info(f),
                                       extra={'spider': spider}))
    return d

經過請求后,response返回給EngineEngine對結果進行處理,可能出現下一個request,也可能解析出我們想要的數據,也就是item,也有可能是失敗,如果請求后得到的是一個request,則會通過scheduler判斷request是否重復,并將request放入請求隊列

downloader返回的response交給我們的engine,engine會判斷response的類型

def _handle_downloader_output(self, response, request, spider):
    assert isinstance(response, (Request, Response, Failure)), response
    # downloader middleware can return requests (for example, redirects)
    if isinstance(response, Request):
        self.crawl(response, spider)
        return
    # response is a Response or Failure
    d = self.scraper.enqueue_scrape(response, request, spider)
    d.addErrback(lambda f: logger.error('Error while enqueuing downloader output',
                                    exc_info=failure_to_exc_info(f),
                                    extra={'spider': spider}))
    return d

如果是一個Request,如可能出現重定向等情況,則會再次調用crawl進入scheduler隊列。如果是response或是錯誤則會調用scraper.enqueue_scrape(response, request, spider)。在這個方法內會經過一系列的調用、注冊回調,會判斷如果不是錯誤則會調用call_spider這個方法

def call_spider(self, result, request, spider):
    result.request = request
    dfd = defer_result(result)
    dfd.addCallbacks(request.callback or spider.parse, request.errback)
    return dfd.addCallback(iterate_spider_output)

可以看到在這個方法內,addCallbacks注冊回調方法,取得request.callback,如果未定義則調用Spiderparse方法,也就是我們自己爬蟲zhihu_spider的邏輯,在我們的邏輯處理后會出現兩種,一種就是item,一種就是request,并將其yield,此時會調用在_scrape中注冊的回調函數handle_spider_outputspider返回的數據進行處理。在這個方法內,有調用了_process_spidermw_output對我們的yield出去的數據進行處理

def _process_spidermw_output(self, output, request, response, spider):
    """Process each Request/Item (given in the output parameter) returned
    from the given spider
    """
    if isinstance(output, Request):
        self.crawler.engine.crawl(request=output, spider=spider)
    elif isinstance(output, (BaseItem, dict)):
        self.slot.itemproc_size += 1
        dfd = self.itemproc.process_item(output, spider)
        dfd.addBoth(self._itemproc_finished, output, response, spider)
        return dfd
    elif output is None:
        pass
    else:
        typename = type(output).__name__
        logger.error('Spider must return Request, BaseItem, dict or None, '
                     'got %(typename)r in %(request)s',
                     {'request': request, 'typename': typename},
                     extra={'spider': spider})

此時可以看到,在_process_spidermw_outputoutput進行了類型判斷,如果是Request,則調用engine.crawl,進入scheduler的處理檢驗指紋并加入優先級隊列等等,如果是BaseItem則會調用pipelineprocess_item方法

經過spider邏輯處理后,會有兩種結果,可能出現下一個request,也可能解析出我們想要的數據,也就是item

Scrapy-redispipeline

class RedisPipeline(object):

    def __init__(self, server,
                 key=defaults.PIPELINE_KEY,
                 serialize_func=default_serialize):
        self.server = server    #Redis client instance.
        self.key = key    # redis key ,value stored serialized key
        self.serialize = serialize_func   #Items serializer function

    @classmethod
    def from_crawler(cls, crawler):
        return cls.from_settings(crawler.settings)  
        #init pipeline from setting

    def process_item(self, item, spider):
        return deferToThread(self._process_item, item, spider)
        # twisted 異步線程的接口,將item的處理變為異步的,防止源源不斷的item處理阻塞

    def _process_item(self, item, spider):
        key = self.item_key(item, spider)
        data = self.serialize(item)
        self.server.rpush(key, data)
        return item

    def item_key(self, item, spider):

        return self.key % {'spider': spider.name}

遵循Scrapy pipeline的寫法,在_process_spidermw_output被調用,只是將Scrapy處理得到的item經過序列化之后放入Redis的列表。

這樣造成的結果就是爬取到的數據都放入到了Redis,如果我們想和之前標準的scrapy一樣將數據放入其他的數據庫或做一些處理,我們需要重新編寫一個處理item的一個文件

提醒
雖然scrapy-redis源碼組成簡單,但是由于scrapy-redisScrapy的源碼依賴過多,需要更加細致的解讀只能讀者自行閱讀Scrapy的源碼。關于Scrapy-Redis的具體使用可以查閱源碼和官方倉庫中的setting.py文件中的注釋。

實現

修改setting.py文件

SCHEDULER = "scrapy_redis.scheduler.Scheduler"
# Ensure all spiders share same duplicates filter through redis.
DUPEFILTER_CLASS = "scrapy_redis.dupefilter.RFPDupeFilter"

ITEM_PIPELINES = {
#     'ScrapyRedisZhihu.pipelines.MysqlTwsitedPipeline': 300,
    'scrapy_redis.pipelines.RedisPipeline': 301
}

DOWNLOADER_MIDDLEWARES = {
        'scrapy.contrib.downloadermiddleware.useragent.UserAgentMiddleware' : None,
        'ScrapyRedisZhihu.middlewares.ProxyMiddleware' :400
}

DOWNLOAD_DELAY = 0.4

MYSQL_HOST='x.x.x.x'
MYSQL_NAME='crawler'
MYSQL_USER='root'
MYSQL_PASS='xxxxxx'

REDIS_HOST='x.x.x.x'
REDIS_PORT=9999

注意
我們將SCHEDULERDUPEFILTER_CLASSITEM_PIPELINES都設置為Scrapy-Redis提供的。其中SCHEDULER被重新實現,使用Redislistzset分別實現了三種隊列,LifoQueueFifoQueuePriorityQueue

dupefilter使用了redisset

ITEM_PIPELINES也將item序列化后放入了redislist。它不是必須的,但是我想嘗試下Redis的威力,所以使用了這個pipeline,如果使用RedisPipeline的話,我們需要重新編寫一個文件來處理序列化(默認為json)到Redis里面的Item,通常命名為process_item.py

修改zhihu_spider.py文件

import scrapy
import json
from ScrapyRedisZhihu.items import ZhihuUserItem,ZhihuUserItemLoader
from datetime import datetime
from scrapy_redis.spiders import RedisSpider

following_api = "https://www.zhihu.com/api/v4/members/{}/followees?include=data[*].gender%2Cvoteup_count%2Cthanked_Count%2Cfollower_count%2Cfollowing_count%2Canswer_count%2Carticles_count%2Cfavorite_count%2Cfavorited_count%2Cthanked_count%2Cbadge[%3F(type%3Dbest_answerer)].topics&offset=0&limit=20"


class ZhihuSpider(RedisSpider):
    name = 'zhihu_redis'
    allowed_domains = ["www.zhihu.com"]
    redis_key = 'zhihu_redis:start_urls'

    def parse(self, response):
        json_response = json.loads(response.body_as_unicode())

        if not json_response['paging']['is_end']:
            yield scrapy.Request(url=json_response['paging']['next'])

        if json_response['data']:
            for data in json_response['data']:
                url_token = data.get('url_token')
                if url_token:
                    yield scrapy.Request(url=following_api.format(url_token))

                    agreed_count = data['voteup_count']
                    thxd_count = data['thanked_count']
                    collected_count = data['favorited_count']
                    if thxd_count or collected_count:
                        item_loader = ZhihuUserItemLoader(item=ZhihuUserItem(), response=response)
                        item_loader.add_value('name',data['name'])
                        item_loader.add_value('id',data['id'])
                        item_loader.add_value('url_token',data['url_token'])
                        item_loader.add_value('headline',data['headline']
                                                            if data['headline'] else "無")
                        item_loader.add_value('answer_count',data['answer_count'])
                        item_loader.add_value('articles_count',data['articles_count'])
                        item_loader.add_value('gender',data['gender']
                                                            if data['gender'] else 0)
                        item_loader.add_value('avatar_url',data['avatar_url_template'].format(size='xl'))
                        item_loader.add_value('user_type',data['user_type'])
                        item_loader.add_value('badge',','.join([badge.get('description') for badge in data['badge']])
                                                            if data.get('badge') else "無")
                        item_loader.add_value('follower_count',data['follower_count'])
                        item_loader.add_value('following_count',data['following_count'])
                        item_loader.add_value('agreed_count',agreed_count)
                        item_loader.add_value('thxd_count',thxd_count)
                        item_loader.add_value('collected_count',collected_count)
                       # item_loader.add_value('craw_time',datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
                        zhihu_item = item_loader.load_item()
                        yield zhihu_item

即可

使用Docker部署

關于Docker的安裝和基本使用這里就不再贅述,后期應該會針對寫一篇關于容器的文章。

搭建私有倉庫

為了將我們的容器上傳到云服務器上而不是一個服務器一個服務器的打包,我們需要搭建一個私有的docker倉庫,首先我們需要運行下面命令獲取registry鏡像
sudo docker pull registry:2.1.1

然后啟動一個容器

sudo docker run -d -v /opt/registry:/var/lib/registry -p 5000:5000 --restart=always --name registry registry:2.1.1

Registry服務默認會將上傳的鏡像保存在容器的/var/lib/registry,我們將主機的/opt/registry目錄掛載到該目錄,即可實現將鏡像保存

運行sudo docker ps保證registry鏡像啟動的容器正在運行即可

打包部署

在項目根目錄生成依賴文件:pip freeze > requirements.txt

編寫Dockerfile文件,和main.py在同一個目錄下。

FROM hub.c.163.com/sportscool/python3

MAINTAINER zhxfei <dylan@zhxfei.com>

ENV PATH /usr/bin:$PATH

ADD . /code

WORKDIR /code

RUN pip install -r requirements.txt -i https://pypi.douban.com/simple

CMD scrapy crawl zhihu_redis

生成鏡像: sudo docker build -t IP:PORT/NAME .

這個IP:PORT應為私有倉庫的registry服務監聽的地址和端口,啟動-t就已經制定了鏡像對應的倉庫

push鏡像到私有倉庫: sudo docker push IP:PORT/NAME

使用curl來確認是否上傳:

zhxfei@HP-ENVY:~/just4fun$ curl 120.x.x.x:5000/v2/_catalog
{"repositories":["scrapy_redis"]}

有之前生成的NAME即可,如此處應為scrapy_redis

之后在每臺云服務器上運行sudo docker pull IP:PORT/NAME 將鏡像下載到本地,再啟動一個容器即可,由于打包鏡像時指定了CMD,運行鏡像即會運行python main.py,爬蟲就已經啟動了

當然,也可以使用一些自動化運維的工具,在本機上遠程批量執行命令,如Ansible,使用

sudo ansible ALL_HOSTS -m shell -a "docker pull IP:PORT/NAME && docker run -d -P IP:PORT/NAME" --sudo
最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,606評論 6 533
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,582評論 3 418
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,540評論 0 376
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,028評論 1 314
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,801評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,223評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,294評論 3 442
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,442評論 0 289
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,976評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,800評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,996評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,543評論 5 360
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,233評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,662評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,926評論 1 286
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,702評論 3 392
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,991評論 2 374

推薦閱讀更多精彩內容