豆瓣电影同步到Notion

抓取豆瓣电影信息,基于Notion搭建私人电影管理系统

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         豆瓣电影同步到Notion
// @namespace    https://greasyfork.org/zh-CN/scripts/464467-%E8%B1%86%E7%93%A3%E8%AF%BB%E4%B9%A6%E5%90%8C%E6%AD%A5%E5%88%B0notion
// @version      1.8
// @description  抓取豆瓣电影信息,基于Notion搭建私人电影管理系统
// @author       @Yanwudong
// @match        https://movie.douban.com/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=douban.com
// @require      https://code.jquery.com/jquery-3.6.0.min.js
// @grant        GM_xmlhttpRequest
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_addStyle
// @license      GNU GPLv3
// ==/UserScript==
(function() {
    'use strict';

    // =========================
    // 样式:对齐图书脚本 + 豆瓣风格
    // =========================
    GM_addStyle(`
  .toast {
    position: fixed;
    top: 150px;
    right: 50%;
    transform: translateX(50%);
    z-index: 9999;
    opacity: 0;
    transition: opacity 0.2s ease-in-out;
  }
  .toast.show { opacity: 1; }
  .toast-body{
    background-color: #f2f8f2;
    color:#4f946e;
    padding: 8px 12px;
    box-shadow: 0 0 10px rgba(0,0,0,0.15);
    border-radius:4px;
  }
  #nlog {
    top: 130px;
    right: 50%;
    transform: translateX(50%);
    z-index: 9999;
    opacity: 0;
    transition: opacity 0.2s ease-in-out;
    box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18);
    background: #ffffff;
    overflow: hidden;
    padding: 28px 28px 20px;
    position: fixed;
    display : none; /* 默认隐藏 */
    border-radius:8px;
    width: 360px;
    border: 1px solid #e5e5e5;
  }
  .modal-footer button{
    height: 36px;
    width: auto;
    margin-bottom: 0;
    flex: 1;
  }
  #nlog.show { opacity: 1; }
  .form-group{ margin-bottom: 10px; }
  .form-control{ border: 1px solid #e4e6e5; border-radius: 3px; box-sizing: border-box; font-size: 13px; padding: 8px; width: 100%; }
  #exampleModalLabel{ cursor: default; font-size: 18px; line-height: 1.8; text-align: center; color: #333; font-weight: 600; margin-bottom:18px; border-bottom: 1px solid #e5e5e5; padding-bottom: 6px; }
  #saveBtn{ background-color: #41ac52; border: 1px solid #41ac52; color: #fff; cursor: pointer; font-size: 14px; font-weight: 600; width: 100%; border-radius: 3px; box-shadow: none; text-align:center; }
  #saveBtn:hover{ background-color: #41ac52; border-color:#41ac52; color:#fff; cursor:pointer; }
  #cancelBtn{ border: 1px solid #e5e5e5; background:#fff; color:#666; font-size:13px; border-radius:3px; cursor:pointer; }
  #cancelBtn:hover{ background:#fff; border-color:#e5e5e5; color:#666; cursor:pointer; }
  .modal-footer{ display:flex; justify-content:space-between; align-items:center; margin-top:14px; gap:10px; }
  #resetBtn{ border: 1px solid #e5e5e5; background:#fafafa; color:#666; cursor:pointer; font-size:12px; padding:6px 10px; border-radius:3px; margin-left:8px; }
  .modal-bottom{ margin-top:14px; font-size:12px; text-align:center; color:#999; }
  .modal-bottom a{ color:#41ac52; margin:0 6px; text-decoration:none; cursor:pointer; }
  .modal-bottom a:hover{ text-decoration:underline; color:#41ac52; }
  #twitter{ margin-left:8px; }
  #syncbt {
    display: inline-block;
    margin-left: 10px;
    font-size: 13px;
    color:#4f946e;
    background-color: #f2f8f2;
    padding: 2px 8px;
    border: 1px solid #b9dcd0;
    border-radius:3px
  }
  #syncbt:hover { cursor: pointer; }
  #resetInlineBtn { display:inline-block; margin-left: 6px; font-size: 12px; color:#999; background-color:#f7f7f7; padding: 1px 6px; border: 1px solid #ddd; border-radius:3px; }
  #resetInlineBtn:hover { cursor:pointer; background-color:#f0f0f0; }
`);

    // =========================
    // 设置弹窗 DOM 模板
    // =========================
    const modalHtml = `
  <div>
    <div id="exampleModalLabel">Notion API 设置</div>
    <div>
      <div class="form-group"><input type="text" class="form-control" id="apiInput" placeholder="密钥 Notion Secret"/></div>
      <div class="form-group"><input type="text" class="form-control" id="databaseIdInput" placeholder="数据库 Database ID"/></div>
      <div class="modal-footer">
        <button type="button" id="saveBtn">保存</button>
        <button type="button" id="cancelBtn">取消</button>
      </div>
      <div class="modal-bottom">
        <a href="https://seemly-pear-9fc.notion.site/Notion-e0ae1a1d391143abb9ff383730649149" id="refBtn" target="_blank">操作说明</a>
        <a href="https://twitter.com/yanwudong" id="twitter" target="_blank">Twitter</a>
      </div>
    </div>
  </div>
`;

    // =========================
    // 轻提示与设置框
    // =========================
    const toast = document.createElement('div');
    toast.id = 'toast';
    toast.className = 'toast';
    toast.innerHTML = `<div class="toast-body"></div>`;
    document.body.appendChild(toast);

    const nlog = document.createElement('div');
    nlog.id = 'nlog';
    nlog.innerHTML = modalHtml;
    document.body.appendChild(nlog);

    // =========================
    // 配置与 Notion 通用参数
    // =========================
    let nToken = GM_getValue('nToken') || '';
    // 单独为电影使用一个键,避免与图书数据库冲突
    let databaseId = GM_getValue('movieDatabaseId') || '';
    const notionVersion = '2022-06-28';
    // 当前数据库中实际用于标题的属性名(默认期望为“电影名”,但会自动适配已有数据库)
    let titlePropertyName = '电影名';
    let pendingSync = false;

    function showToast(text, ms = 3000) {
        toast.querySelector('.toast-body').innerText = text;
        toast.classList.add('show');
        clearTimeout(toast._t);
        toast._t = setTimeout(() => toast.classList.remove('show'), ms);
    }

    // =========================
    // Notion 请求封装
    // =========================
    function notionRequest(url, method = 'GET', body = null) {
        return new Promise((resolve, reject) => {
            const headers = {
                'Notion-Version': notionVersion,
                'Authorization': 'Bearer ' + nToken,
                'Content-Type': 'application/json'
            };
            GM_xmlhttpRequest({
                method: method,
                url: url,
                headers: headers,
                data: body ? JSON.stringify(body) : null,
                onload: function(resp) {
                    try {
                        const json = JSON.parse(resp.responseText);
                        if (json && json.object === 'error') reject(json);
                        else resolve(json);
                    } catch (e) {
                        reject({ message: 'Invalid JSON response', raw: resp.responseText });
                    }
                },
                onerror: function(err) {
                    reject(err);
                }
            });
        });
    }

    // =========================
    // 获取电影信息
    // =========================
    function getMovieInfo(){
        const infos = document.querySelectorAll('#info .pl');
        let movie = {};

        // 创建类型数组
        let type = '';
        let typeList = [];

        debugger
        movie['电影名'] = document.querySelector('#content > h1 > span').innerText;
        movie['封面'] = document.querySelector('#mainpic > a > img').src;
        const grade = document.querySelector('.rating_num');
        if(grade != null){
            movie['评分'] = document.querySelector('.rating_num').innerText;
        }
        movie['豆瓣链接']=window.location.href;
        movie['简介']=document.querySelector('#link-report-intra').innerText;
        debugger
        //循环遍历infos,并把每个元素赋值给info
        for(const info of infos){
            if(info.innerText === '导演'){
                movie['导演'] = info.nextSibling.nextSibling.innerText
            }else if(info.innerText === '编剧'){
                movie['编剧'] = info.nextSibling.nextSibling.innerText
            }else if(info.innerText === '主演'){
                movie['主演'] = info.nextSibling.nextSibling.innerText
            }else if(info.innerText === '上映日期:'){
                movie['上映日期'] = info.nextSibling.nextSibling.innerText
            }else if(info.innerText === '片长:'){
                movie['片长'] = info.nextSibling.nextSibling.innerText
            }else if(info.innerText === '类型:'){
                // 找到所有具有属性 "property" 且属性值为 "v:genre" 的元素
                const genreElements = document.querySelectorAll('span[property="v:genre"]');
                // 遍历每个元素,将其内容添加到数组中
                genreElements.forEach(function(element) {
                typeList.push(element.textContent.trim());
                });
                movie['类型'] = typeList
            }else{
               let prop = info.innerText.substr(0,info.innerText.length-1)
                movie[prop] = info.nextSibling.data
            }
        }
        return movie
    }

    // =========================
    // 数据库结构补全(标签 / 状态 / 评价 等)
    // =========================
    async function ensureDatabaseSchema() {
        let dbMeta = null;
        try {
            dbMeta = await notionRequest('https://api.notion.com/v1/databases/' + databaseId, 'GET');
            // 识别已有的 title 字段,避免重复创建导致 “Cannot create new title property”
            if (dbMeta && dbMeta.properties) {
                for (const name in dbMeta.properties) {
                    const prop = dbMeta.properties[name];
                    if (prop && prop.type === 'title') {
                        titlePropertyName = name || '电影名';
                        break;
                    }
                }
            }
        } catch (e) {
            console.warn('获取电影数据库结构失败,将继续尝试更新字段', e);
        }
        const existingProps = (dbMeta && dbMeta.properties) || {};
        const properties = {};

        // 状态:select
        if (!existingProps['状态'] || existingProps['状态'].type !== 'select') {
            properties['状态'] = {
                select: {
                    options: [
                        { name: '🌑想看', color: 'purple' },
                        { name: '🌒在看', color: 'orange' },
                        { name: '🌕看过', color: 'green' }
                    ]
                }
            };
        }
        // 评价:select
        if (!existingProps['评价'] || existingProps['评价'].type !== 'select') {
            properties['评价'] = {
                select: {
                    options: [
                        { name: '⭐️⭐️⭐️⭐️⭐️' },
                        { name: '⭐️⭐️⭐️⭐️' },
                        { name: '⭐️⭐️⭐️' },
                        { name: '⭐️⭐️' },
                        { name: '⭐️' }
                    ]
                }
            };
        }

        // 其他字段若不存在则补齐
        // 只有在数据库本身没有别的 title 字段、且 titlePropertyName 仍为“电影名”时,才尝试创建新的 title 字段
        if (titlePropertyName === '电影名' && !existingProps['电影名']) {
            properties['电影名'] = { title: {} };
        }
        if (!existingProps['导演']) properties['导演'] = { rich_text: {} };
        if (!existingProps['主演']) properties['主演'] = { rich_text: {} };
        if (!existingProps['简介']) properties['简介'] = { rich_text: {} };
        if (!existingProps['上映日期']) properties['上映日期'] = { rich_text: {} };
        if (!existingProps['片长']) properties['片长'] = { number: { format: 'number' } };
        if (!existingProps['豆瓣评分']) properties['豆瓣评分'] = { number: { format: 'number' } };
        if (!existingProps['IMDb']) properties['IMDb'] = { rich_text: {} };
        if (!existingProps['封面']) properties['封面'] = { files: {} };
        if (!existingProps['豆瓣链接']) properties['豆瓣链接'] = { url: {} };
        if (!existingProps['地区']) properties['地区'] = { select: {} };
        if (!existingProps['类型']) properties['类型'] = { multi_select: {} };

        if (Object.keys(properties).length === 0) {
            return dbMeta;
        }
        const body = { properties };
        return notionRequest('https://api.notion.com/v1/databases/' + databaseId, 'PATCH', body);
    }

    // =========================
    // 创建电影条目
    // =========================
    function createMovieItem(movie) {
        const body = {
            parent: { type: 'database_id', database_id: databaseId },
            icon: { type: 'emoji', emoji: '🎞️' },
            properties: {
                [titlePropertyName]: {
                    title: [{ type: 'text', text: { content: movie['电影名'] || '' } }]
                },
                '封面': {
                    files: movie['封面']
                        ? [{
                            type: 'external',
                            name: 'cover',
                            external: { url: movie['封面'] }
                        }]
                        : []
                },
                'IMDb': {
                    rich_text: [{ type: 'text', text: { content: movie['IMDb'] || '' } }]
                },
                '片长': {
                    number: movie['片长'] ? parseInt(movie['片长']) : null
                },
                '状态': {
                    select: { name: '🌑想看' }
                },
                '评价': {
                    select: { name: '⭐️⭐️⭐️⭐️⭐️' }
                },
                '上映日期': {
                    rich_text: [{ type: 'text', text: { content: movie['上映日期'] || '' } }]
                },
                '豆瓣评分': {
                    number: movie['评分'] ? parseFloat(movie['评分']) : null
                },
                '导演': {
                    rich_text: [{ type: 'text', text: { content: movie['导演'] || '' } }]
                },
                '主演': {
                    rich_text: [{ type: 'text', text: { content: movie['主演'] || '' } }]
                },
                '简介': {
                    rich_text: [{ type: 'text', text: { content: movie['简介'] || '' } }]
                },
                '地区': {
                    select: { name: movie['制片国家/地区'] || '' }
                },
                '类型': {
                    multi_select: (movie['类型'] || []).map(tag => ({ name: tag }))
                },
                '豆瓣链接': {
                    url: movie['豆瓣链接'] || ''
                }
            }
        };

        return notionRequest('https://api.notion.com/v1/pages', 'POST', body)
            .then(res => {
                showToast('同步成功!');
                return res;
            });
    }

    // =========================
    // 同步主流程
    // =========================
    async function syncToNotion() {
        if (!nToken || !databaseId) {
            pendingSync = true;
            showSettings();
            return;
        }
        try {
            showToast('正在检查/更新 Notion 数据库结构...');
            await ensureDatabaseSchema();
            const movie = getMovieInfo();
            await createMovieItem(movie);
        } catch (err) {
            console.error('notion movie error', err);
            const msg = (err && err.message) ? err.message : '同步失败,请查看控制台';
            showToast(msg);
        }
    }

    // =========================
    // 设置窗口显示/隐藏 & 事件
    // =========================
    function showSettings() {
        const apiInput = document.getElementById('apiInput');
        const databaseIdInput = document.getElementById('databaseIdInput');
        if (apiInput) apiInput.value = nToken || '';
        if (databaseIdInput) databaseIdInput.value = databaseId || '';
        nlog.style.display = 'block';
        setTimeout(() => nlog.classList.add('show'), 10);
    }
    function hideSettings() {
        nlog.classList.remove('show');
        setTimeout(() => nlog.style.display = 'none', 200);
    }

    $(document).ready(function() {
        $(document).on('click', '#saveBtn', async function() {
            const apiVal = $('#apiInput').val().trim();
            const dbVal = $('#databaseIdInput').val().trim();
            if (!apiVal || !dbVal) {
                showToast('请填写 Token 和 Database ID');
                return;
            }
            nToken = apiVal;
            GM_setValue('nToken', nToken);
            databaseId = dbVal;
            GM_setValue('movieDatabaseId', databaseId);

            hideSettings();
            try {
                await ensureDatabaseSchema();
                showToast('配置已保存并更新数据库字段');
                if (pendingSync) {
                    pendingSync = false;
                    await syncToNotion();
                }
            } catch (err) {
                console.error(err);
                showToast('更新数据库字段失败,请检查 Token 与 Database ID');
            }
        });

        $(document).on('click', '#cancelBtn', function() {
            pendingSync = false;
            hideSettings();
        });
    });

    // =========================
    // 添加同步按钮
    // =========================
    function addButton(){
        const button = document.createElement('button');
        button.innerText = '同步到Notion';
        button.id = 'syncbt';
        button.addEventListener('click', function() {
            if (nToken && databaseId) {
                syncToNotion();
            } else {
                pendingSync = true;
                showSettings();
            }
        });

        const resetInlineBtn = document.createElement('button');
        resetInlineBtn.id = 'resetInlineBtn';
        resetInlineBtn.innerText = '重置';
        resetInlineBtn.addEventListener('click', function () {
            GM_setValue('nToken', '');
            GM_setValue('movieDatabaseId', '');
            nToken = '';
            databaseId = '';
            showToast('已重置配置,将打开设置重新填写');
            pendingSync = false;
            showSettings();
        });

        const actions = document.querySelector('#content > h1 > .year ');
        if (actions) {
            actions.insertAdjacentElement('afterend', resetInlineBtn);
            resetInlineBtn.insertAdjacentElement('beforebegin', button);
        }
    }

    addButton();
})();