Python+Obsidian(Projects)导出豆瓣书单

源码
python_projects_list.png
python_projects_gallary.png

豆瓣书单只是一个简单的收藏,不好排序,不好打标签,不好搜索。曾经手动一条一条的整理到“语雀”上,但数据量多了非常卡,滑着滑着就一片空白,有一次想选中一个单元格,Ctrl A 选中所有文字删除,结果 UI 响应慢,没有任何效果,把所有都删除了,并且删除也看不到效果,于是一刷新也无法撤销了,只见每次刷新页面数据都删一点,直到全部空白,版本记录中也是什么都没有,恢复不了,问客服也没法解决,气得我直接销户再也不用了。

后来自己用 AnyType 了,在上面也整理了几十条,但终归是麻烦,耽误时间,弄多了手都疼了。也想过用 Python 下载下来放到 Obsidian 中,但是没有数据表,不好管理,大名鼎鼎的 DataView 插件还要自己学语法,而且丑陋,不想用,直到后来看到有个 Projects 插件,虽然功能不如那些软件,但也能用了,于是把 Python 的笔记重新看看,回忆回忆语法,尝试写个脚本,下载图书内容,然后作为 Obsidian 的文档属性。

获取网页 html

第一步就是要获取网页的 html 用来提取数据,最初尝试的就是 requests 库,豆瓣的响应码直接是 418,经过查询,说是要设置请求头,加上 User-Agent。

import requests


def get_html_text(url):
    headers = {'User-Agent':'浏览器请求,查看浏览器中的 User-Agent'}
    res = requests.get(url, headers=headers)
    res.raise_for_status()
    return res.text

解析豆列书单

import bs4


html_first = get_html_text(url)
soup = bs4.BeautifulSoup(html_first, 'html.parser')
if name := soup.select_one('#content > h1 > span'):
    name = name.text.strip()  # 拿到书单的名称

书单是分页的,一页一页加载

class Doulie:
    def load_books(self, soup):
        if items := soup.select('.doulist-item'):
            for item in items:
                if title := item.select_one('.title'):
                    if title := title.select_one('a'):
                        book_url = title.get('href')  # 拿到每本书的 url
                        # 解析图书并写到 markdown 文件
            if next_page_url := self._get_next_page(soup):
                # 如果有下一页,再取出下一页的内容解析
                html_next = get_html_text(next_page_url)
                soup_next = bs4.BeautifulSoup(html_next, 'html.parser')
                self.load_books(soup_next)  # 递归一页一页往后找


    """获取豆列下一页"""
    def _get_next_page(self, soup):
        next_page_url = ''
        if paginator := soup.select_one('.paginator'):
            if next := paginator.select_one('.next'):
                if next_url := next.select_one('a'):
                    next_page_url = next_url.get('href')
        return next_page_url

解析图书信息

在豆列书单的循环中,拿到每一本书的 url,获取它的 html 内容根据自己的需求解析,比如需要解析出标题,作者,iSBN,封面,副标题,页数,内容简介。

页数可能是 123 这种数字,也可能是 234页,还可能是“两册“这种纯文本,所有用正则表达式匹配一下。

内容简介很长的话,页面上会有个“展开全部”,源码里是一个 class 为 all-hidden 的 span,那里面才是完整的内容,如果一般长度,页面上没有“展开全部”,但源码里也是有 all-hidden 的 span,内容和 class 为 short 的 span 一样,但有些内容非常短,就不存在 all-hidden/short 了,所以要区分开来。

class Book:
    def load_book(self, html):
        soup = bs4.BeautifulSoup(html, 'html.parser')

        # 这几个信息直接在 meta 里有
        if title := self._get_content(soup, 'meta[property="og:title"]'):
            self.title = title

        if author := self._get_content(soup, 'meta[property="book:author"]'):
            self.author = author

        if isbn := self._get_content(soup, 'meta[property="book:isbn"]'):
            self.isbn = isbn

        if image := self._get_content(soup, 'meta[property="og:image"]'):
            self.image = image

        for pl in soup.select('.pl'):
            text = pl.text.strip()
            if '副标题' in text:  # 这些不一定存在,内容是标签后的纯文本
                if sub_title := str(pl.next_sibling).strip():
                    self.sub_title = sub_title
            elif '页数' in text:
                if pages := str(pl.next_sibling).strip():
                    if pages := re.compile(r'\d+').search(pages):  # 过滤出数字
                        self.pages = pages.group()

        # 内容简介被包装在多个 p 标签里
        description = ''
        # 内容简介比较长的情况
        des = soup.select('#link-report > span.all.hidden > div > div > p')
        if not des:
            # 只有很短的简介
            des = soup.select('#link-report > div > div > p')
        if des:
            desp = map(lambda p: p.text, des)
            description = list(desp)
            self.description = description


    def _get_content(self, soup, css):
        if select := soup.select_one(css):
            if content := select.get('content'):
                return content.strip()

输出 markdown 文件

如果本地已经存在同名文件,是怎么处理,可能要进行交互,我这里就直接保留两份了。

"""
path 是文件要保存的目录
"""
def write_2_md(path: Path, book: Book):
    if not path.exists():
        path.mkdir(parents=True)  # 如果不创建文件夹,会报错

    mdPath = getFinalPath(path, book)
    if not mdPath:
        return

    with open(mdPath, 'w', encoding='UTF-8') as f:
        f.writelines(book.get_export_md())


def getFinalPath(path: Path, book: Book):
    mdPath = path/f'{book.title}.md'
    if mdPath.exists():
        mdPath = path/f'{book.title}_{datetime.datetime.now().strftime("%Y%m%d%H%M%S")}.md'
    return mdPath

文件要怎么写,放在 Book 类里了。

def get_export_md(self, tag):
    content_list = []
    content_list.append('---\n')
    content_list.append(f'地址: "{self.url}"\n')
    if self.image:
        content_list.append(f'封面: "{self.image}"\n')
    content_list.append(f'书名: "{self.title}"\n')
    if self.sub_title:
        content_list.append(f'副标题: "{self.sub_title}"\n')
    if self.author:
        content_list.append(f'作者: "{self.author}"\n')
    if self.isbn:
        content_list.append(f'ISBN: "{self.isbn}"\n')
    if self.pages:
        content_list.append(f'页数: {self.pages}\n')
    content_list.append('---\n\n')

    # 上面文档属性写完了
    if self.image:  # 文章中显示图片
        content_list.append(f'![{self.title}|400]({self.image})\n\n')
    if self.description:  # 图片下面显示简介
        content_list.append('## 简介\n\n')
        for des in self.description:
            content_list.append(f'{des.strip()}\n\n')
    return content_list

反爬

一开始一个书单都下载下来了,然后再来响应码就突然 403 了,搜索网上资料,原因是豆瓣的反爬机制,尝试网上的一些策略,比如多个 User-Agent 随机选择,请求之间间隔一段时间,多个代理 ip 随机选一个,结果全都失败。

然后想那我用 selenium 操作浏览器模拟人工操作总可以了吧,没想到还是失败,再根据网上的一些反爬策略设置,依然无效。

最后实在没办法了,那我就用 pyautogui 去操作 UI 完全模仿人工这总是可以了吧。先用 webbrowser 模块打开浏览器,然后打开源代码,自己复制 html 内容总行了吧。

import webbrowser
from time import sleep
import pyautogui
import pyperclip

webbrowser.open(url)  # 浏览器打开网页
sleep(5)  # 等待网页加载,根据自己网速来,也不一定要等完完全全加载出来
wh = pyautogui.size() # 获取屏幕尺寸
pyautogui.click(wh.width/4, wh.height/2)  # 我把浏览器放在屏幕左边,点击鼠标,让浏览器高亮
pyautogui.hotkey('command', 'u')  # Mac 系统快捷键 Cmd+U,打开源代码页面
sleep(10)  # 等待源代码加载出来
pyautogui.hotkey('command', 'a')  # 全选
sleep(1)
pyautogui.hotkey('command', 'c')  # 把源代码复制到系统剪贴板
text = pyperclip.paste()  # 从剪贴板取出源代码
pyautogui.hotkey('command', 'w')  # 关闭源代码窗口
pyautogui.hotkey('command', 'w')  # 关闭网页窗口