Ang Sinabi Ko

Heto ako. Kukunin ko ang mga sinabi ko.

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 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;
    });
})();