RSS(Really Simple Syndication, 简易信息聚合)是一种用于聚合发布经常更新的网站内容的一种格式规范,在一些启用RSS源的网站上,通过访问其RSS源可以获得目标网站的最新发布信息,也可以通过目标网站提供的一些筛选手段,获得感兴趣的那部分内容。因此RSS最大的特征就在于其时效性,查陈年老黄历显然没办法用RSS来完成,但有时候确实需要查一下,那就需要将RSS数据持久化。在本篇文章中,我们选用关系型数据库MySQL为例,并以Python语言为工具,示范一个持久化的例子。

首先需要明确一点,RSS的定义是十分松散的,一个字段可以是任何类型的数据(尽管从技术上讲,数据的类型都是字符串),因此就数据表结构而言,就必须具体问题具体分析,从实际出发根据需求建表(那些主张一键转换的歪果仁都是些犯形而上学错误的家伙)。当然什么都不管,就是想把RSS储存下来,也不是不可以,就是数据库的体积会稍微大一些(通常会大30倍),详情可以直接跳到第二章

马克思主义活的灵魂(笑)

目标RSS源是Torrent发布站点nyaa.si,该站提供RSS源,地址为https://nyaa.si/?page=rss,观察RSS字段,我们可以发现有下列几个我们会比较感兴趣。那么接下来就根据这些字段建表,例如名叫nyaa

字段
字段语义
guid
全局唯一编号
title描述字符串
nyaa:category分类字符串
nyaa:infoHash信息散列
link链接地址
pubDate发布时间
nyaa:size大小信息
CREATE TABLE `nyaa` (
`sid` INT(10) UNSIGNED NOT NULL,
`title` TEXT NOT NULL COLLATE 'utf8mb4_unicode_ci',
`link` TEXT NOT NULL COLLATE 'utf8mb4_unicode_ci',
`pubtime` INT(10) UNSIGNED NOT NULL,
`size` VARCHAR(20) NOT NULL COMMENT 'File size' COLLATE 'utf8mb4_unicode_ci',
`cls` TINYTEXT NOT NULL COMMENT 'Class of feed' COLLATE 'utf8mb4_unicode_ci',
`infohash` CHAR(40) NOT NULL COLLATE 'utf8mb4_unicode_ci',
PRIMARY KEY (`sid`),
INDEX `infohash` (`infohash`)
)
COMMENT='Feeds From nyaa'
COLLATE='utf8mb4_unicode_ci'
ENGINE=InnoDB
ROW_FORMAT=COMPRESSED
;

需要提醒的是,注意到ROW_FORMAT=COMPRESSED,该表使用了压缩的行格式。这是因为在表定义中使用了很多不定长数据类型TEXT,同时link字段下会有大量重复的片段,因此使用压缩会有效降低数据表大小。另一方面注意到`pubtime` INT(10) UNSIGNED NOT NULL,,时间这一项,在这里用了无符号整型来储存,当然使用TIMESTAMP类型储存也是完全没问题的,倒不如说,那样更合情合理,因此可以根据个人偏好决定。

尽管对于link字段下的重复片段可以用程序手动进行压缩(比如只储存变动部分,不变部分硬编码到程序中),但正如前面所说的,RSS是定义十分松散的,能以原始数据储存的,尽量以原始数据储存。比起在持久化完成后的后处理中Raise Exception,在持久化过程中Raise Exception更令人生厌。

接下来就是主要的Python代码,代码假定表结构不再变化,且字段顺序不变(因为那样代码会短很多)。对付重复数据,这里采用的是INSERT IGNORE(第26行),当然用ON DUPLICATE KEY UPDATE也是没有问题的。当然数据库连接那边的具体参数(第40行),也要实事求是。值得注意的是由于executemany(第43行)对INSERT操作有魔法buff增益,请务必不要用for循环与execute,否则性能损失空前巨大。

PROXIES={}
DB_USER=None
DB_NAME=None
DB_HOST=None


def restruct(entry):
    # 按数据库列顺序重排
    from time import mktime
    if entry:
        return (
            int(entry.id.split('/')[-1]),
            entry.title,
            entry.link,
            round(mktime(entry.published_parsed)),
            entry.nyaa_size,
            entry.nyaa_category,
            entry.nyaa_infohash,
        )
    return 7


def store():
    rssurl = 'https://nyaa.si/?page=rss'

    sqlsentence = 'INSERT IGNORE INTO nyaa VALUES ({place});'.format(place=','.join(['%s'] * restruct(None)),)

    import requests as req
    from feedparser import parse

    ret = req.get(url=rssurl, proxies=PROXIES)

    if not ret.ok:
        raise RuntimeError('Response %s', ret.status_code)

    parsedfeed = parse(ret.content)

    from mysql.connector import connect

    connection = connect(user=DB_USER, db=DB_NAME, host=DB_HOST)
    cursor = connection.cursor()

    cursor.executemany(sqlsentence, map(restruct, parsedfeed.entries))

    connection.commit()


if __name__ == '__main__':
    store()

新时代中国特色方法

你有没有发现如果用马克思主义经典作家的方法,那么当你需要爬好多RSS的时候,会遇到一个写了好多相同的代码——如果能将代码妥善地包装起来,只要写一个配置文件那该多好。

🎉 锵锵锵 🎉
全球首个(暂定)RSS转SQL工具(Python实现)上线啦 !
🎉 🎉 🎉
命令行输入pip install rss2sql即刻拥有
🎉 🎉 🎉
详情请访问https://github.com/jsjyhzy/rss2sql

安装完成之后就只需要编写配置文件,配置文件使用的yaml语法,接下来用一个例子,稍微解释一下该怎么写配置文件

rss:
  url: https://nyaa.si/?page=rss
  proxies:
    https: http://localhost:8188
sql:
  tablename: nyaa
  field:
    - name: nyaa_id
      val: "None"
      type: INT
      nullable: false
      primary_key: true
      autoincrement: true
    - name: isSukebei
      val: "False"
      type: BOOLEAN
    - name: title
      val: "x.get('title')"
      type: TEXT
    - name: link
      val: "x.get('link')"
      type: VARCHAR
      type_parameter: 50
      unique: true
    - name: category
      val: "x.get('nyaa_category')"
      type: REFTABLE
      type_parameter:
        - VARCHAR
        - 50
    - name: pubtime
      val: "ToolKit.struct_time_To_datetime(x.get('published_parsed'))"
      type: TIMESTAMP
      index: true

RSS 段

这里主要定义两个东西,RSS的地址(url)和访问RSS时使用的代理(proxies),如果不需要使用代理,那么proxies那里留空也许,当然proxies都不写也行

SQL 段

这一块就是最繁重的部分,主要定义两方面内容:表名(tablename)和表字段(fields),表名就不多说了,不要写些奇奇怪怪的名字就行,重点在于表字段。

自增主键

有的时候只是单纯想要一个主键,以便于其他表建立外键,虽说通常我们可以选那些unique的字段当主键,但unique的字段很有可能时VARCHAR,那么其他表建立外键时就要用这种类型,无形中就增加了其他表的大小。这时候可以选择一个INT类型作为主键。比如在例子中,我们建立了一个nyaa_id
(通过定义name) 的主键(通过定义primary_keytrue);其类型为INT(通过定义type);不可空置(通过定义nullablefalse);自增(通过定义autoincrementtrue);取值为None(通过定义val"None"),亦即插入记录时,该字段无值,那么数据库会自动分配一个值。这样就完成了自增主键的定义

常量

可能会奇怪为什么有常量字段,其实这通常会发生在一个RSS具有表世界与里世界两个世界,然后就是想把两个世界的记录放在一张表里。比如例子中isSukebei字段取值恒为"False",要注意这个False首字母要大写,
Python 里是 True/False;
YAML 里是 true/false。

最小配置

注意到定义title字段时取值是"x.get('title')",这里面的x就是feedparser解析完成之后每一个RSS item的字典对象。因为只不过是个字典,当然写起来能写出"x['title']",但这不是用get方法还能设置默认值嘛。

含参数的字段

某些类型,需要一些参数,比如说VARCHAR,需要定义最大长度,那就可以通过定义type_parameter50来取得VARCHAR(50)

参考表(Reference Table)

很多时候,会遇到某些字段是个类型语义的,直接储存为VARCHAR,恕我直言,过于愚蠢。而作为ENUM类型储存,倒也不是不可以,只不过ENUM要增加一个种类时,会比较令人揪心。完全不会增加删减类别的字段又可遇不可求,比如“性别”这个语义本来只需要“男”或“女”,看起来好像不需要变动,现在不也是….😂。因此在标准SQL类型上,追加了一个REFTABLE类型,将这个愚蠢的VARCHAR移到另一张表,变成一个睿智的VARCHAR(通过 type_parameter定义了VARCHAR(50)),再用一个INT类型作为沟通桥梁。

一行写不下的取值

val定义的东西,程序中是通过eval方法对其求值。有些东西对于eval方法来说,它就是不太好实现,比如说要引入其他库的方法,对取值进行一下转换, 最常见的问题就是如何将RSS中的时间字符串,变换成数据库支持的时间字符串。eval的环境变量中有一个ToolKit的类,这个类中包含着一些常见的转换方法,比如在例子中用ToolKit.struct_time_To_datetime方法将time.struct_time类型转换为了datetime.datetime类型,从而让SQLAlchemy得以自动转换。
当然如果ToolKit里没有要用的函数,现阶段只能fork之后自己加上去,以后也许会加入可以向ToolKit动态追加方法的功能吧😀

当然提个Pull request也是欢迎的。

未来形而上学

首先,要搞明白什么叫做将RSS储存下来。RSS究其本质是一个XML标记文件,当我们用Python的feedparser库解析完成之后,对于每一个entry而言(或者说是RSS规定的概念 item ),就是一个Python的Dict对象。就从Duck Typing哲学的角度讲,我只要把这个Dict对象储存起来,那我就相当于把RSS储存了下来;于此同时,也可以把XML保存下来(看起来很蠢,其实这也算不上太蠢,储存的时候相当于可以做 column compression ,相比MySQL只能做 row compression ,体积会小很多。比如一个6k的json格式推特推文数据集,储存于MySQL占用22MB,然而导出为sql文件,哪怕只用zip压缩一下,也不过3.9MB 🤣 ),但只用关系型数据库实现起来,不是很合适,所以姑且不谈。

接下来就是要思考如何唯一确定一个Dict对象。关于这一点,看起来有点难,其实很简单:feedparser解析后产生的entry,必有一个id属性。

最后就是如何储存这个Dict对象。那就更简单了,MySQL在5.7.8版本后支持JSON类型,然后把这个对象变成JSON,就只需要from json import dumps; dumps(...)

可能需要提醒的一点就是,RSS规范要求 item 必须要有三个字段:title、link、description,对应Dict对象就是title、link、summary三个属性。设计数据表的时候如果有需要用到FULLTEXT、UNIQUE索引,可以把这几个字段单列出来;如果只是需要对这几个字段做个普通索引或者干脆不做索引,那直接做个虚列就可以了。

留下评论