Greasy Fork

Greasy Fork is available in English.

Anki_Search

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

/**
 * version change
 *  - fix replace target width
 */

const URL = "http://127.0.0.1:8765"
const SEARCH_FROM = "-deck:English"
const MAX_CARDS = 37

// set card size
const MIN_CARD_WIDTH = 30
const MAX_CARD_WIDTH = 40
const MAX_CARD_HEIGHT = 70
const MAX_IMG_WIDTH = MAX_CARD_WIDTH - 3

// adaptor
const HOST_MAP = new Map([
    ["local", ["#anki-q", "#anki-card"]],
    ["google", ["#APjFqb", "#rhs"]],
    ["bing", ["#sb_form_q", "#b_context"]],
    ["yahoo", ["#yschsp", "#right"]],
    ["baidu", ["#kw", "#content_right"]],
    ["anki", [".form-control", "#content_right"]],
    ["mijisou", ["#q", "#sidebar_results"]],
    // ["duckduckgo", ["#search_form_input", ".results--sidebar"]],
])

const INPUT_WAIT_MS = 700


// utils
function log() {
    console.log.apply(console, arguments)
}


function* counter() {
    /**
     * 计数器,统计请求次数
     */
    let val = 0
    let skip = 0
    while (true) {
        skip = yield val
        val = val + 1 + (skip === undefined ? 0 : skip)
    }
}
let g_counterReqText = counter()
let g_counterReqSrc = counter()
g_counterReqText.next()
g_counterReqSrc.next()


class Singleton {
    constructor() {
        const instance = this.constructor.instance
        if (instance) {
            return instance
        }
        this.constructor.instance = this
    }
}


// request and data
class Api{
    static _commonData(action, params) {
        /**
         * 请求表单的共同数据结构
         * action: str findNotes notesInfo
         * params: dict
         * return: dict
         */
        return {
            "action": action,
            "version": 6,
            "params": params
        }
    }

    static async _searchByText(searchText) {
        /**
         * 通过文本查卡片ID
         */
        let query = `${SEARCH_FROM} ${searchText}`
        let data = this._commonData("findNotes", { "query": query })
        try {
            let response = await fetch(URL, {
                method: "POST",
                body: JSON.stringify(data)
            })
            g_counterReqText.next()
            return await response.json()
        } catch (error) {
            console.log("Request searchByText Failed", error)
        }
    }

    static async _searchByID(ids) {
        /**
         * 通过卡片ID获取卡片内容
         */
        let data = this._commonData("notesInfo", { "notes": ids })
        try {
            let response = await fetch(URL, {
                method: "POST",
                body: JSON.stringify(data)
            })
            g_counterReqText.next()
            return await response.json()
        } catch (error) {
            console.log("Request searchByID Failed", error)
        }
    }

    static async searchImg(filename) {
        /**
         * 搜索文件名 返回 资源的base64编码
         * return base64 code
         */
        let data = this._commonData("retrieveMediaFile", { "filename": filename })
        try {
            let response = await fetch(URL, {
                method: "POST",
                body: JSON.stringify(data)
            })
            let res = await response.json()
            g_counterReqSrc.next()
            return res.result
        } catch (error) {
            log("Request searchImg Failed", error, filename)
        }
    }

    static formatBase64Img(base64) {
        let src = `data:image/png;base64,${base64}`
        return src
    }

    static async searchImgBase64(filename) {
        let res = await this.searchImg(filename)
        let base64Img = this.formatBase64Img(res)
        return base64Img
    }

    static async search(searchText) {
        /**
         * 结合两次请求, 一次完整的搜索
         * searchValue: 搜索框的内容
         */
        if (searchText.length === 0) {
            return []
        }
        try {
            let idRes = await this._searchByText(searchText)
            let ids = idRes.result
            ids.length >= MAX_CARDS ? ids.length = MAX_CARDS : null
            let cardRes = await this._searchByID(ids)
            let cards = cardRes.result
            return cards
        } catch (error) {
            log("Request search Failed", error, searchText)
        }
    }
}


class Card {
    constructor(id, index, frontCardContent, backCardData, parent) {
        this.id = id
        this.index = index
        this.isfirstChild = index === 1
        this.frontCardContent = frontCardContent  // strContent
        this.backCardData = backCardData  // [order, field, content]
        this.backCardData.sort((i, j) => i > j ? 1 : -1)
        this.parent = parent

        this._cardHTML = null
        this._title = null
        this.isExtend = null
        this.bodyDom = null
        this.titleDom = null
    }

    get title() {
        let title = ""
        let parseTitle = this.frontCardContent.split(/<div.*?>/)
        let blankHead = parseTitle[0].split(/\s+/)
        //有div的情况
        if (this.frontCardContent.includes("</div>")) {
            // 第一个div之前不是全部都是空白,就是标题
            if (!/^\s+$/.test(blankHead[0]) && blankHead[0] !== "") {
                title = blankHead
            } else {
                // 标题是第一个div标签的内容
                title = parseTitle[1].split("</div>")[0]
            }
        } else {
            //没有div的情况
            title = this.frontCardContent
        }
        this._title = title
        title = this.index + "、" + title
        return title
    }

    get forntCard() {
        if (this._title === this.frontCardContent) {
            let arrow = `<span style="padding-left: 4.5em;">↓</span>`
            let arrows = ""
            for (let index = 0; index < 4; index++) {
                arrows = arrows + arrow
            }
            return `<div style="text-align: center;">↓${arrows}</div>`
        }
        return this.frontCardContent
    }

    get backCard() {
        let back = ""
        if (this.backCardData.length <= 1) {
            back += this.backCardData[0][2]
        } else {
            this.backCardData.forEach(item => {
                let order, field, content
                [order, field, content] = item
                if (content.length > 0) {
                    back += `<div class="anki-sub-title"><em>${field}</em></div>
                             <div calss="anki-sub-back-card">${content}</div><br>`
                }
            })
        }
        return back
    }

    get templateCard() {
        let template = `
            <div class="anki-card anki-card-size">
              <div class="anki-title" id="title-${this.id}">${this.title}</div>
              <div class="anki-body" id="body-${this.id}">
                <div class="anki-front-card">${this.forntCard}</div>
                <div class="anki-back-card">${this.backCard}</div>
              </div>
            </div>
            `
        return template
    }

    get cardHTML() {
        if (!this._cardHTML) {
            throw "pls requestCardSrc first"
        }
        return this._cardHTML
    }

    set cardHTML(cardHTML) {
        this._cardHTML = cardHTML
    }

    async replaceImg(templateCard) {
        let reSrc = /src="(.*?)"/g
        let reFilename = /src="(?<filename>.*?)"/
        let srcsList = templateCard.match(reSrc)
        let temp = templateCard

        if (!srcsList) {
            return temp
        }

        await Promise.all(srcsList.map(async (i) => {
            let filename = i.match(reFilename).groups.filename
            let base64Img = await Api.searchImgBase64(filename)
            let orgImg = `<img src="${filename}"`
            let replaceImg = `<img class="anki-img-width" src="${base64Img}"`
            temp = temp.replace(orgImg, replaceImg)
        }))

        return temp
    }

    async requestCardSrc() {
        let templateCard = await this.replaceImg(this.templateCard)
        this.cardHTML = templateCard
        return templateCard
    }

    showSelTitleClass(show) {
        let selTitleClass = "anki-title-sel"
        show 
            ? this.titleDom.classList.add(selTitleClass)
            : this.titleDom.classList.remove(selTitleClass)
    }

    setExtend(show) {
        if (this.isExtend === show) {
            return
        } else {
            let hideClass = "anki-collapsed"
            let showClass = "anki-extend"
            if (show) {
                this.bodyDom.classList.add(showClass)
                this.bodyDom.classList.remove(hideClass)
            } else {
                this.bodyDom.classList.add(hideClass)
                this.bodyDom.classList.remove(showClass)
            }

            this.isExtend = show
            this.showSelTitleClass(show)
        }
    }

    tryCollapse() {
        if (!this.isfirstChild) {
            this.setExtend(false)
            return
        }
        this.isExtend = true
        this.showSelTitleClass(true)
    }

    listenEvent() {
        this.titleDom = window.top.document.getElementById(`title-${this.id}`)
        this.titleDom.addEventListener("click", () => this.onClick())

        this.bodyDom = window.top.document.getElementById(`body-${this.id}`)
        this.bodyDom.addEventListener("animationend", () => this.onAniEnd())
    }

    onClick() {
        this.parent.onCardClick(this)
        let show = !this.isExtend
        this.setExtend(show)
    }

    onAniEnd() {
        if (this.isExtend) {
            window.scroll(window.outerWidth, window.pageYOffset)
        }
    }

    onInsert() {
        this.listenEvent()
        this.tryCollapse()
    }

}


class CardMgr extends Singleton {
    constructor () {
        super()
        this.cards = []
    }

    formatCardsData(cardsData) {
        /** turn cardData 2 cardObj */
        let cards = []
        cardsData.forEach((item, index) => {
            let id = item.noteId
            let frontCard = []
            let backCards = []
            for (const [k, v] of Object.entries(item.fields)) {
                if (v.order === 0) {
                    frontCard = v.value
                    continue
                }
                backCards.push([v.order, k, v.value])
            }
            let card = new Card(id, index+1, frontCard, backCards, this)
            cards.push(card)
        })
        return cards
    }

    insertCardsDom(cards) {
        if (!DomOper.getContainer()) {
            return
        }
        DomOper.clearContainer()
        cards.forEach(card => {
            DomOper.getContainer().insertAdjacentHTML("beforeend", card.cardHTML)
            card.onInsert()
        })
    }

    async searchAndInsertCard(searchValue) {
        DomOper.insertContainerOnce()
        if (!DomOper.getContainer()) {
            return
        }
        let cardsData = await Api.search(searchValue)
        let cards = this.formatCardsData(cardsData)
        this.cards = cards
        await Promise.all(cards.map(async (card) => await card.requestCardSrc()))
        this.insertCardsDom(cards)
        log(
            `total req: ${g_counterReqText.next(-1).value + g_counterReqSrc.next(-1).value}\n`,
            `req searchText: ${g_counterReqText.next(-1).value}\n`,
            `req searchSrc: ${g_counterReqSrc.next(-1).value}\n`,
        )
    }

    onCardClick(curCard) {
        this.cards.forEach( card => {
            if (card !== curCard) {
                card.setExtend(false)
            }
        })
    }

}


// dom
const REPLACE_TARGET_ID = "anki-replace-target"
const REPLACE_TARGET = `<div id="${REPLACE_TARGET_ID}"><div>`

const CONTAINER_ID = "anki-container"
const CONTAINER = `<div id="${CONTAINER_ID}"><div>`

class DomOper {
    static getHostSearchInputAndTarget() {
        /**
         * 获取当前网站的搜索输入框 与 需要插入的位置
         *  */
        let host = window.location.host || "local"
        let searchInput = null  // 搜索框
        let targetDom = null    // 左边栏的父节点
        this.removeReplaceTargetDom()

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

        return [searchInput, targetDom]
    }

    // listen input
    static addInputEventListener(searchInput) {
        function onSearchTextInput(event) {
            lastInputTs = event.timeStamp
            searchText = event.srcElement.value
            setTimeout(() => {
                if (event.timeStamp === lastInputTs) {
                    new CardMgr().searchAndInsertCard(searchText)
                }
            }, INPUT_WAIT_MS)
        }
        let lastInputTs, searchText
        searchInput.addEventListener("input", onSearchTextInput)
    }

    static getReplaceTargetDom() {
        return window.top.document.getElementById(REPLACE_TARGET_ID)
    }

    static createReplaceTargetDom() {
        let targetDomParent = window.top.document.getElementById("rcnt")
        if (targetDomParent) {
            targetDomParent.insertAdjacentHTML("beforeend", REPLACE_TARGET)
        }
    }

    static getOrCreateReplaceTargetDom() {
        if (!this.getReplaceTargetDom()) {
            this.createReplaceTargetDom()
        }
        return this.getReplaceTargetDom()
    }

    static removeReplaceTargetDom () {
        if (!this.getReplaceTargetDom()) {
            return
        }
        this.getReplaceTargetDom().remove()
    }

    static insertCssStyle() {
        let headDom = window.top.document.getElementsByTagName("HEAD")[0]
        headDom.insertAdjacentHTML("beforeend", style)
    }

    static insertContainerOnce(targetDom) {
        if (this.getContainer()) {
            return
        }
        targetDom = targetDom ? targetDom : this.getHostSearchInputAndTarget()[1]
        if (!targetDom) {
            log("AKS can't insert cards container", targetDom)
            return
        }
        targetDom.insertAdjacentHTML("afterbegin", CONTAINER)
        this.insertCssStyle()
    }

    static getContainer() {
        return window.top.document.getElementById(CONTAINER_ID)
    }

    static clearContainer() {
        this.getContainer().innerHTML = ""
    }

    static replaceImgHTML(html, filename, base64Img) {
        let orgImg = `<img src="${filename}"`
        let replaceImg = `<img class="anki-img-width" src="${base64Img}"`
        html = html.replace(orgImg, replaceImg)
        return html
    }

}


async function main() {
    log("Anki Serarch Launching")
    let [searchInput, targetDom] = DomOper.getHostSearchInputAndTarget()
    if (!searchInput) {
        log("AKS can't find search input", searchInput)
        return
    }
    DomOper.addInputEventListener(searchInput)
    DomOper.insertContainerOnce(targetDom)

    let searchText = searchInput.value
    new CardMgr().searchAndInsertCard(searchText)
}


window.onload = main


const style = `
  <style>
  /*card*/
  .anki-card-size {
    min-width: ${MIN_CARD_WIDTH}em; 
    max-width: ${MAX_CARD_WIDTH}em;
    max-height: ${MAX_CARD_HEIGHT}em;
  }

  .anki-img-width {
    max-width: ${MAX_IMG_WIDTH}em; 
  }
 
  .anki-card {
    position: relative;
    display: -ms-flexbox;
    display: flex;
    -ms-flex-direction: column;
    flex-direction: column;
    word-wrap: break-word;
    width:fit-content; 
    width:-webkit-fit-content;
    width:-moz-fit-content;
    margin-bottom: .25em;
    border: .1em solid #69928f;
    // border-radius: calc(.7em - 1px);
    border-radius: .7em;
  }

  .anki-body {
    overflow-x: visible;
    overflow-y: auto;
  }

  /* card title */
  .anki-title {
    padding: .75em;
    margin: 0em;
    font-weight: 700;
    color: black;
    background-color: #e0f6f9;
    // border-radius: calc(.5em - 1px);
    border-radius: .7em;

    transition-property: all;
    transition-duration: 1.5s;
    transition-timing-function: ease-out;
  }

  .anki-title-sel {
      animation-name: select-title;
      animation-duration: 5s;
      animation-iteration-count: infinite;
      animation-direction: alternate;
  }

  .anki-title:hover{
    // background-color: #9791b1;
    background-color: #d2e4f9;
  }

  .anki-sub-title {
    color: #5F9EA0;
  }

  .anki-front-card {
    padding: .75em;
    border-bottom: solid .3em #c6e1e4;
  }

  .anki-back-card {
    padding: .75em .75em;
  }

  .anki-collapsed {
    overflow: hidden;
    animation-name: collapsed;
    animation-duration: .3s;
    animation-timing-function: ease-out;
    animation-fill-mode:forwards;
    animation-direction: normal;
  }

  .anki-extend {
    overflow-x: visible;
    animation-name: extend;
    animation-duration: .3s;
    animation-timing-function: ease-in;
    animation-fill-mode:forwards;
    animation-direction: normal;
  }

  div#anki-container ul {
    margin-bottom: 1em;
    margin-left: 2em;
  }

  div#anki-container ol {
    margin-bottom: 1em;
    margin-left: 2em;
  }

  div#anki-container ul li{
    list-style-type: disc;
  }

  div#anki-container ul ul li{
    list-style-type: circle;
  }

  div#anki-container ul ul ul li{
    list-style-type: square;
  }

  div#anki-container ul ul ul ul li{
    list-style-type: circle;
  }

  div#anki-replace-target {
    margin-left: 2em;
    width: ${MIN_CARD_WIDTH}em;
    max-width: ${MAX_CARD_WIDTH}em;
    float: right;
    display: block;
    position: relative;
}

  @keyframes collapsed
    {
      0%   {max-height: ${MAX_CARD_HEIGHT}em; max-width: ${MAX_CARD_WIDTH}em;}
      100% {max-height: 0em; max-width: 30em;}
    }

  @keyframes extend
    {
      0%   {max-height: 0em; max-width: ${MIN_CARD_WIDTH}em;}
      100% {max-height: ${MAX_CARD_WIDTH}em; max-width: ${MAX_CARD_WIDTH}em;}
    }

  @keyframes select-title
    {
      0%   {background: #e0f6f9;}
      50%  {background: #e1ddf3;}
      100% {background: #d2e4f9;}
    }

  /**
   * hljs css
   */
  pre code.hljs {
    display: block;
    overflow-x: auto;
    padding: 1em
  }
  
  code.hljs {
    padding: 3px 5px
  }
  
  .hljs {
    color: #e0e2e4;
    background: #282b2e
  }
  
  .hljs-keyword, .hljs-literal, .hljs-selector-id, .hljs-selector-tag {
    color: #93c763
  }
  
  .hljs-number {
    color: #ffcd22
  }
  
  .hljs-attribute {
    color: #668bb0
  }
  
  .hljs-link, .hljs-regexp {
    color: #d39745
  }
  
  .hljs-meta {
    color: #557182
  }
  
  .hljs-addition, .hljs-built_in, .hljs-bullet, .hljs-emphasis, .hljs-name, .hljs-selector-attr, .hljs-selector-pseudo, .hljs-subst, .hljs-tag, .hljs-template-tag, .hljs-template-variable, .hljs-type, .hljs-variable {
    color: #8cbbad
  }
  
  .hljs-string, .hljs-symbol {
    color: #ec7600
  }
  
  .hljs-comment, .hljs-deletion, .hljs-quote {
    color: #818e96
  }
  
  .hljs-selector-class {
    color: #a082bd
  }
  
  .hljs-doctag, .hljs-keyword, .hljs-literal, .hljs-name, .hljs-section, .hljs-selector-tag, .hljs-strong, .hljs-title, .hljs-type {
    font-weight: 700
  }
  
  .hljs-class .hljs-title, .hljs-code, .hljs-section, .hljs-title.class_ {
    color: #fff
  }
  </style>
`