Ang Sinabi Ko

Heto ako. Kukunin ko ang mga sinabi ko.

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name    Ang Sinabi Ko
// @description Heto ako. Kukunin ko ang mga sinabi ko.
// @match   https://m.douban.com/people/*
// @version 1.2
// @grant   unsafeWindow
// @grant   GM_xmlhttpRequest
// @run-at  document-end
// @license MIT
// @namespace https://greasyfork.org/users/219930
// ==/UserScript==

// 用 Promise 简单地封装一下 GM_xmlhttpRequest
function get(url) {
    return new Promise((resolve, reject) => {
        GM_xmlhttpRequest({
            method: 'GET',
            url,
            headers: {
                Referer: 'https://m.douban.com/',
            },
            onload: (res) => resolve(JSON.parse(res.response)),
            onerror: reject,
        });
    });
}

(async () => {
    // 获取当前登录用户相关信息
    const { user } = unsafeWindow.__INITIAL_STATE__;

    // 简易的交互界面
    const panel = document.createElement('div');
    panel.style.cssText = `
    position: fixed;
    top: calc(50% - 5em);
    left: calc(50% - 10em);
    z-index: 9999;
    display: flex;
    flex-direction: column;
    align-items: center;
    width: 20em;
    height: 10em;
    padding: 2em 2em 1em;
    border-radius: 0.5em;
    background-color: #fff;
    box-shadow:
        0px 0px 3.6px -2px rgba(0, 0, 0, 0.035),
        0px 0px 10px -2px rgba(0, 0, 0, 0.05),
        0px 0px 24.1px -2px rgba(0, 0, 0, 0.065),
        0px 0px 80px -2px rgba(0, 0, 0, 0.1);
    font-size: 1.25em;
    `;

    const closeButton = document.createElement('button');
    closeButton.type = 'button';
    closeButton.setAttribute('aria-label', '关闭');
    closeButton.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" /></svg>';
    closeButton.style.cssText = `
    position: absolute;
    top: 0.5em;
    right: 0.5em;
    padding: 0.25em;
    background: transparent;
    border: 0;
    width: 1.8em;
    height: 1.8em;
    color: #777;
    cursor: pointer;
    `;
    closeButton.addEventListener('click', () => panel.remove());

    const desc = document.createElement('p');
    desc.innerHTML = `当前登录用户: <strong>${user.name}<strong>`;

    const button = document.createElement('button');
    button.type = 'button';
    button.style.cssText = `
    border: 0;
    padding: 0.5em 0.8em;
    border-radius: 1.25em;
    background-color: #42bd56;
    color: #fff;
    font-size: 1.125em;
    cursor: pointer;
    `;
    button.textContent = '抓取当前用户广播数据';

    panel.append(closeButton, desc, button);
    document.body.append(panel);

    // 爬取逻辑
    const fetchPosts = (month, filter = '') => get(`https://m.douban.com/rexxar/api/v2/user/${user.id}/lifestream?slice=month-${month}&hot=false&filter_after=${filter}&count=50&ck=azd0&for_mobile=1`);

    button.addEventListener('click', async () => {
        button.disabled = true;
        const tip = document.createElement('p');
        tip.textContent = '抓取中,预计需要几分钟,完成后将自动下载数据';
        tip.style.fontSize = '0.75em';
        panel.append(tip);

        // 获取当前用户的注册年份和月份
        const { reg_time: regTime } = await get(`https://m.douban.com/rexxar/api/v2/user/${user.id}?ck=azd0&for_mobile=1`);
        const regTimeParts = regTime.split('-');
        const regYear = Number(regTimeParts[0]);
        const regMonth = Number(regTimeParts[1]);

        // 获取当下的年份和月份
        const date = new Date();
        const currentYear = date.getFullYear();
        const currentMonth = date.getMonth() + 1;

        // 计算从注册时间到当下时间之间的所有月份
        const months = new Array(currentYear - regYear + 1).fill(null)
            .map((_, i) => regYear + i)
            .flatMap((year) => {
                const startMonth = (year === regYear) ? regMonth : 1;
                const endMonth = (year === currentYear) ? currentMonth : 12;
                const monthCount = endMonth - startMonth + 1;
                return new Array(monthCount).fill(null)
                    .map((_, j) => `${year}-${j + startMonth}`);
            });

        // 通过豆瓣移动端的公共接口逐一爬取每个月份的广播
        const posts = {};
        for (let i = 0; i < months.length; i++) {
            const month = months[i];
            const data = await fetchPosts(month);
            posts[month] = data.items;

            // 该接口一次性最多只能获取50条,若存在`next_filter_after`则说明该月份还有更多广播,需要继续获取
            let nextFilterAfter = data.next_filter_after;
            while (nextFilterAfter) {
                const data = await fetchPosts(month, nextFilterAfter);
                posts[month].push(...data.items);
                nextFilterAfter = data.next_filter_after;
            }
        }

        // 以json文件形式下载获取完的所有广播
        const file = new File([JSON.stringify(posts, null, 4)], { type: 'plain/text' });
        const url = URL.createObjectURL(file);
        const link = document.createElement('a');
        link.download = 'douban.json';
        link.href = url;
        link.click();

        tip.textContent = '抓取完毕,请在你的下载目录里查找 douban.json 文件'
        button.disabled = false;
    });
})();