// ==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>
`