Anki_Search

同步搜索Anki上的内容,支持google、bing、yahoo、百度。依赖AnkiConnect(插件:2055492159)

目前為 2019-10-10 提交的版本,檢視 最新版本

// ==UserScript==
// @name         Anki_Search
// @namespace    https://github.com/yekingyan/anki_search_on_web/
// @version      0.6
// @description  同步搜索Anki上的内容,支持google、bing、yahoo、百度。依赖AnkiConnect(插件:2055492159)
// @author       Yekingyan
// @run-at       document-start
// @require      https://code.jquery.com/jquery-3.3.1.min.js
// @include      https://www.google.com/*
// @include      https://www.google.com.*/*
// @include      https://www.google.co.*/*
// @include      https://www.bing.com/*
// @include      http://www.bing.com/*
// @include      https://cn.bing.com/*
// @include      https://search.yahoo.com/*
// @include      https://www.baidu.com/*
// @include      http://www.baidu.com/*
// @include      https://ankiweb.net/*
// @grant        unsafeWindow
// ==/UserScript==

(function () {
    'use strict'
    //---------------------------------------------------------------------------------

    // AnkiConnect(插件:2055492159)的接口
    const local_url = 'http://127.0.0.1:8765'

    // 搜索范围设置
    // 只搜English牌组,'deck:English'
    // 排除English牌组,'-deck:English'
    const SEARCH_FROM = ''

    // 卡页类型,正反面的名称
    // 默认supermemo不用设置
    // 目前只持支取两个字段
    const FRONT_CARCD_FILES = ''
    const BACK_CARK_FILES = ''

    // 依赖
    let requiredScript = [
`
  <script src="https://cdn.staticfile.org/jquery/3.2.1/jquery.min.js"></script>
`,
`
  <style>

  /*card*/

  .anki-mb-1 {
    margin-bottom: .25rem!important;
  }

  .anki-mb-1, .my-1 {
    margin-bottom: .25rem!important;
  }
  .anki-rounded {
    border-radius: .25rem!important;
  }

  /*
  .anki-border-success {
    border-color: #dfe1e5!important;
  }
  */

  .anki-card {
    position: relative;
    display: -ms-flexbox;
    display: flex;
    -ms-flex-direction: column;
    flex-direction: column;
    min-width: 0;
    word-wrap: break-word;
    background-color: #fff;
    background-clip: border-box;
    border: 1.5px solid #dfe1e5;
    border-radius: .25rem;
  }

  /* cardheader */
  .anki-card-header:first-child {
    border-radius: calc(.25rem - 1px) calc(.25rem - 1px) 0 0;
  }
  .anki-font-weight-bold {
    font-weight: 700!important;
  }

  .bg-title {
  background-color: #c6e1e4!important;
  }

  .anki-card-header {
    padding: .75rem 1.25rem;
    margin-bottom: 0;
    background-color: rgba(0,0,0,.03);
    border-bottom: 1px solid rgba(0,0,0,.125);
  }

  /*card body*/
  .text-success {
    color: #28a745!important;
  }
  .anki-card-body {
    -ms-flex: 1 1 auto;
    flex: 1 1 auto;
    padding: .75rem 1.25rem;
    border-bottom: solid 1px;
  }


  /*card footer*/
  .card-footer:last-child {
    border-radius: 0 0 calc(.25rem - 1px) calc(.25rem - 1px);
  }

  .anki-bg-transparent {
    background-color: transparent!important;
  }

  .anki-card-footer {
    padding: .75rem 1.25rem;
    background-color: rgba(0,0,0,.03);
    border-top: 1px solid rgba(0,0,0,.125);
  }

  </style>
`
    ]
    // 图片等资源的路径, 注意windows要将 反斜杠\ 换成 斜杠/
    // 需开启 web服务器
    // const media_path = `C:/Users/y/AppData/Roaming/Anki2/用户1/collection.media/`
    // const media_url = 'file:///' + media_path

    let WHERE = ''

    const getHostSearchInputAndTarget = () => {
        /**
         * 获取当前网站的搜索输入框 与 需要插入的位置
         *  */
        let host = window.location.host
        let searchInput = [undefined]  // 搜索框
        let targetDom = [undefined]    // 左边栏的父节点

        let HOST_MAP = new Map([
            ['google', ['.gLFyf', '#rhs']],
            ['bing', ['#sb_form_q', '#b_context']],
            ['yahoo', ['#yschsp', '#right']],
            ['baidu', ['#kw', '#content_right']],
            ['anki', ['.form-control', '#content_right']],
            // ['duckduckgo', ['#search_form_input', '.results--sidebar']],
        ])

        for (let [key, value] of HOST_MAP) {
            if (host.includes(key)) {
                WHERE = key
                searchInput = $(value[0], window.top.document)
                targetDom = $(value[1], window.top.document)
                break
            }
        }

        return [searchInput, targetDom]
    }

    const mylog = function () {
        console.log.apply(console, arguments)
    }

    const commonData = (action, params) => {
        /**
         * 请求的共同数据结构
         * action: str findNotes notesInfo
         * params: dict
         * return: dict
         */
        return {
            "action": action,
            "version": 6,
            "params": params
        }
    }


    const searchByFileName = (filename) => {
        /**
         * 搜索文件名 返回 资源的base64编码
         * return base64 code
         */

        // gif 太大了,不要
        // if (filename.includes('.gif')) {
        //   return
        // }

        let data = commonData('retrieveMediaFile', { 'filename': filename })
        data = JSON.stringify(data)
        const _searchByFileName = new Promise((resolve, reject) => {
            $.post(local_url, data)
                .done((res) => {
                    resolve([filename, res.result])
                })
                .fail((err) => {
                    reject(err)
                })
        })
        srcCount = getSrcCount.next().value
        return _searchByFileName
    }

    const searchByTest = (searchText) => {

        /**
         * 搜索文字返回含卡片ID的数组
         * searchText: str 要搜索的内容
         * from:    str 搜索特定的牌组
         * callback:   array noteIds
         */
        let from = '-deck:English'
        if (SEARCH_FROM) {
            from = SEARCH_FROM
        }
        let query = `${from} ${searchText}`
        let data = commonData('findNotes', { 'query': query })
        data = JSON.stringify(data)
        const _searchByTest = new Promise((resolve, reject) => {
            $.post(local_url, data)
                .done((res) => {
                    // 只要前37个结果
                    res.result.length >= 37
                        ? res.result.length = 37
                        : null
                    resolve(res.result)
                })
                .fail((err) => {
                    reject(err)
                })
        })
        idCount = getIdCount.next().value
        return _searchByTest
    }



    const searchByIds = (ids) => {
        /**
         * 通过卡片Id获取卡片列表
         * callback: cards array
         */
        let notes = ids
        let data = commonData('notesInfo', { 'notes': notes })
        data = JSON.stringify(data)

        const _searchByIds = new Promise((resolve, reject) => {
            $.post(local_url, data)
                .done((res) => {
                    resolve(res.result)
                })
                .fail((err) => {
                    reject(err)
                })
        })
        detailCount = getDetailCount.next().value
        return _searchByIds
    }


    const search = (searchValue, callback) => {
        /**
         * 结合两次请求, 一次完整的搜索
         * searchByTest() --> searchByIds()
         * searchValue: 搜索框的内容
         * callback: cards: array
         */

        if (!searchValue.length) {
            return
        }

        searchByTest(searchValue).then((ids) => {
            searchByIds(ids).then((cards) => {
                callback(cards)
                console.log(
                    '总请求次数:', idCount + detailCount + srcCount, '\n',
                    'src请求次数:', srcCount + '\n',
                    'detail请求次数', detailCount + '\n',
                    'id请求次数', idCount + '\n' ,
                )
            })
        })
        
    }

    function* next_id() {
        /**
         * 计数器,统计每个接口请求的次数
         */
        let current_id = 0
        while (true) {
            current_id++
            yield current_id
        }
    }

    let lastCount = next_id()
    let getIdCount = next_id()
    let getDetailCount = next_id()
    let getSrcCount = next_id()
    let idCount = 0
    let detailCount = 0
    let srcCount = 0


    let templateItem = (id, title, frontCard, backCard, show = 'show') => {
        let template = `
    <div class="anki-card anki-border-success anki-mb-1 anki-rounded" style="min-width: 35rem; max-width: 50rem; width:fit-content; width:-webkit-fit-content; width:-moz-fit-content;">
      <div class="anki-card-header bg-title anki-font-weight-bold
      collapsed" id="heading${id}" data-toggle="collapse" aria-expanded="false" data-target="#collapse${id}" aria-controls="collapse${id}">
      ${title}
      </div>

      <div class="collapse ${show}"  id="collapse${id}" aria-labelledby="heading${id}" data-parent="#accordionCard">
        <div class="anki-card-body text-success anki-border-success" >${frontCard}</div>
        <div class="anki-card-footer anki-bg-transparent anki-border-success">${backCard}</div>
      </div>

    </div>
    `
        return template
    }


    // 容器
    const container = `<div id="accordionCard"><div>`

    const insertContainet = (targetDom) => {
        /**
         *  插入容器到页面
         *  */
        let containerDiv = $.parseHTML(container)
        let father = $('#accordionCard', window.top.document)
        if (Object.keys(father).length <= 2) {
            // 根据不同网站加入容器
            targetDom[0].prepend(containerDiv[0])
        }
    }

    const insertCards = (domsArray) => {
        /**
         * 将节点插入到页面中
         * domsArray: array dom节点列表
         * targetDom: 要在页面中依附的元素
         */



        // 多次搜索清空旧结果
        let father = $('#accordionCard', window.top.document)
        father.empty()

        // 添加搜索结果到容器内
        let str, imageDom
        let fit = 'width:fit-content; width:-webkit-fit-content; width:-moz-fit-content;'
        domsArray.forEach((item, index) => {
            father.append(item)

            // 卡片样式在各站点的适配
            switch (WHERE) {
                // case 'google': $(item).attr('style', 'min-width: 40rem; max-width: 45rem;' + fit)
                // break
                case 'bing': $(item).attr('style', 'min-width: 28rem; max-width: 45rem;' + fit)
                    break
                case 'baidu': // 如果想适配百度,需把css的rem 换算,百度的rem 有毒
                    $(item).attr('style', 'min-width: 400px; max-width: 600px;' + fit)
                    $(item).find('.anki-card-footer').attr('style', 'padding: 9.75px 16.25px;')
                    $(item).find('.anki-card-body').attr('style', 'padding: 9.75px 16.25px;')
                    $(item).find('.anki-card-header').attr('style', 'padding: 9.75px 16.25px;')
                    break
                // default: $(item).attr('style', 'min-width: 35rem; max-width: 45rem;' + fit)
                // break
            }


            // 卡片加入时只显示标题
            let collapse = $(item).find('.collapse')
            if (index !== 0) {
                collapse.hide()
            } else {
                lastClick = collapse
            }

            // 获取卡片的str, 用于更替src资源
            str = $(item[1]).html()
            collectSrc(str, (filename, data) => {
                imageDom = $(`img[src="${filename}"]`, window.top.document)
                // dom 更替src属性
                imageDom.attr('src', data)
                //样式 限制图片宽度
                imageDom.attr('style', ' max-width: 520px;')
            })
        })
    }

    const getTittleFromFrontCard = (index, frontCard) => {
        /**
         * 通过FrontCard生成简短的标题, 
         * 并根据标题重新定义frontCard
         */
        let title = ''
        let parseTitle = frontCard.split(/<div.*?>/)
        let blanckHead = parseTitle[0].split(/\s+/)
        //有div的情况
        if (frontCard.includes('</div>')) {
            // 第一个div之前不是全部都是空白,就是标题
            if (!/^\s+$/.test(blanckHead[0]) && blanckHead[0] !== '') {
                title = blanckHead
            } else {
                // 标题是第一个div标签的内容
                title = parseTitle[1].split('</div>')[0]
            }
        } else {
            //没有div的情况
            title = frontCard
            let arrows = `<span style="padding-left: 4.5em">↓</span>`
            frontCard = arrows + arrows + arrows + arrows
        }
        title = index + '、' + title
        return [frontCard, title]
    }


    const src_base64 = (base64) => {
        let src = `
    data:image/png;base64,${base64}
  `
        return src
    }



    // const replaceDivSrc = (str, s_b_map) => {
    //   /**
    //    * 更替div中的src属性
    //    */
    //   let tempStr = str
    //   for (let i of s_b_map) {
    //     tempStr = tempStr.replace(i[0], i[i])
    //   }
    //   return tempStr
    // }


    const collectSrc = (str, callback) => {
        /**
         * 将str资源文件名,提取出来, 并对应base64资源
         * callback filename, base64
         */
        let src, base64, data

        // 找出 src="**.jpg"
        let re_src = /src="(.*?)"/gm
        let srcsList = str.match(re_src)

        if (!srcsList) {
            // 没有资源的卡片
            return str
        }

        // 找出**.jpg
        let filename
        srcsList.forEach(item => {
            filename = item.split('"')[1].split('"')[0]
            // 查文件询名对应的资源
            searchByFileName(filename).then(results => {
                // src -> data
                base64 = results[1]
                data = src_base64(base64)
                // data replace src
                callback(results[0], data)

            })
        })
    }


    const resolveCars = (cards, targetDom) => {
        /**
         * 处理卡片信息
         * cards: array
         *  */
        let id, title, frontCard, backCard, show, isFirst, itemDivs, fileds_f, fileds_b
        FRONT_CARCD_FILES ? fileds_f = FRONT_CARCD_FILES : fileds_f = '正面'
        BACK_CARK_FILES ? fileds_b = BACK_CARK_FILES : fileds_b = '背面'

        isFirst = true
        itemDivs = []
        cards.forEach((item, index) => {
            if (isFirst) {
                // 是否展开,展开第一个
                show = 'show'
                isFirst = false
            } else {
                show = ''
            }

            id = item.noteId
            frontCard = item.fields[fileds_f]['value']
            backCard = item.fields[fileds_b]['value']
                ; ([frontCard, title] = getTittleFromFrontCard(index + 1, frontCard))

            let strDiv = templateItem(id, title, frontCard, backCard, show)
            let itemDiv = $.parseHTML(strDiv)
            itemDivs.push(itemDiv)
        })
        // 处理收集的itemDivs,插入到页面中
        insertCards(itemDivs)
    }


    const seacrhAddEventListener = (searchInput) => {
        /**
         * 搜索框有内容变化触发更新
         */
        let lastSearchText = '' // 最新一次请求的搜索的参数
        let lastTime            // 最后触发事件的时间 
        searchInput.on({

            // 有输入行为
            input: function (e) {
                // 相同的内容就不请求了
                if (lastSearchText !== searchInput.val()) {
                     // 0.7秒内没有新的事件触发,才发一次结合请求
                    lastTime = e.timeStamp
                    setTimeout(() => {
                        if (lastTime - e.timeStamp == 0) {
                            lastSearchText = searchInput.val()
                            search(lastSearchText, (cards) => {
                                console.log('inputEven request tiems', lastCount.next().value, searchInput.val(), cards)
                                resolveCars(cards)
                            })
                        }
                    }, 700)
                }

            },

            // 失焦触发请求
            change: function () {
                console.log('change request', searchInput.val())
                if (lastSearchText !== searchInput.val()) {
                    search(searchInput.val(), (cards) => {
                        resolveCars(cards)
                    })
                }
            },

        })
    }


    let lastClick  // 记录最后一次显示或隐藏的卡片
    const switchCardAddEventListener = () => {
        /**
         * 控制卡片风手琴
         */
        $('#accordionCard', window.top.document).on('click', '.collapsed', function (event) {

            let cardTitle = $(event.target)
            let targetId = cardTitle.data('target')
            let collapse = $(targetId, window.top.document)

            //目标元素的显示与隐藏
            collapse.toggle(500)
            collapse.toggleClass('show')

            // 上一个元素的隐藏,如果是自身则不操作
            if (lastClick && lastClick.attr('id') !== collapse.attr('id')) {
                // 如果有 show的class 则去掉并隐藏
                lastClick.hide(500)
                lastClick.removeClass('show')
            }

            // 如果目标卡片是打开状态,标志
            if (collapse.hasClass('show')) {
                lastClick = collapse
            }

        })
    }


    $(window.top.document).ready(() => {
        // 注入脚本
        if (location.host === "www.baidu.com") {
            // 不注入jquery
            requiredScript = requiredScript.slice(1)
        }
        let html = $.parseHTML(requiredScript.join('\n'), window.top.document, true)
        $('body', window.top.document).append(html)
    })


    $(document).ready(() => {

        // 获取输入框 与 搜索值
        let [searchInput, targetDom] = getHostSearchInputAndTarget()

        // 终止搜索
        if (!searchInput[0]) {
            console.log('在页面没有找到搜索框', searchInput)
            return
        }
        if (!targetDom[0]) {
            console.log('在页面没有找到可依附的元素', targetDom)
            return
        }


        // 插入容器到页面
        insertContainet(targetDom)


        // 刷新,搜索一次
        search(searchInput.val(), (cards) => {
            resolveCars(cards)
        })


        // 输入框的搜索事件监听
        seacrhAddEventListener(searchInput)

        // 控制卡片风手琴的事件监听
        switchCardAddEventListener()

        // 最右

    })

    /**
     * div class="med" id="res" 左侧搜索结果
     * 左边 rhs_block
     * 
     * 
     * <iframe src="chrome-extension://pioclpoplcdbaefihamjohnefbikjilc/SimSearchFrame.html" id="simSearchFrame" style="width: 454px; height: 265px; border: none;"></iframe>
     * 
     */

    const test = (condition, e) => {
        if (!condition) {
            console.log(e)
        }
    }

    //-----------------------------------------------------------------------

})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址