Skip to content

Commit

Permalink
Refactor the database, remove tinglejs, and add qrcode.
Browse files Browse the repository at this point in the history
  • Loading branch information
cdhigh committed Apr 19, 2024
1 parent 380f8ba commit e3fa272
Show file tree
Hide file tree
Showing 50 changed files with 2,216 additions and 1,183 deletions.
90 changes: 66 additions & 24 deletions application/back_end/db_models.py
Expand Up @@ -4,7 +4,7 @@
#Visit <https://github.com/cdhigh/KindleEar> for the latest version
#Author:
# cdhigh <https://github.com/cdhigh>
import os, sys, random
import os, sys, random, hashlib, datetime
from operator import attrgetter
from ..utils import ke_encrypt, ke_decrypt, tz_now

Expand All @@ -15,36 +15,52 @@

class KeUser(MyBaseModel): # kindleEar User
name = CharField(unique=True)
passwd = CharField()
email = CharField()
sender = CharField() #可能等于自己的email,也可能是管理员的email
secret_key = CharField(default='')
kindle_email = CharField(default='')
enable_send = BooleanField(default=False)
passwd_hash = CharField()

send_days = JSONField(default=JSONField.list_default)
send_time = IntegerField(default=6)
timezone = IntegerField(default=0)
expiration_days = IntegerField(default=0) #账号超期设置值,0为永久有效
expires = DateTimeField(null=True) #超过了此日期后账号自动停止推送
created_time = DateTimeField(default=datetime.datetime.utcnow)

device = CharField(default='')
book_type = CharField(default='epub') #mobi,epub
book_title = CharField(default='KindleEar')
title_fmt = CharField(default='') #在元数据标题中添加日期的格式
author_format = CharField(default='') #修正Kindle 5.9.x固件的bug【将作者显示为日期】
book_mode = CharField(default='') #书籍模式,'periodical'|'comic',漫画模式可以直接全屏
remove_hyperlinks = CharField(default='') #去掉文本或图片上的超链接{'' | 'image' | 'text' | 'all'}
time_fmt = CharField(default='%Y-%m-%d')
oldest_article = IntegerField(default=7)
book_language = CharField(default='en') #自定义RSS的语言
enable_custom_rss = BooleanField(default=False)
#email,kindle_email,secret_key,enable_send,timezone
#sender: 可能等于自己的email,也可能是管理员的email
base_config = JSONField(default=JSONField.dict_default)

#device,type,title,title_fmt,author_fmt,mode,time_fmt,oldest_article,language
#rm_links: 去掉文本或图片上的超链接{'' | 'image' | 'text' | 'all'}
book_config = JSONField(default=JSONField.dict_default)

share_links = JSONField(default=JSONField.dict_default) #evernote/wiz/pocket/instapaper包含子字典,微博/facebook/twitter等仅包含0/1
covers = JSONField(default=JSONField.dict_default) #保存封面图片数据库ID {'order':,'cover0':,...'cover6':}
send_mail_service = JSONField(default=JSONField.dict_default) #{'service':,...}
custom = JSONField(default=JSONField.dict_default) #留着扩展,避免后续一些小特性还需要升级数据表结构

#通过这两个基本配置信息的函数,提供一些合理的初始化值
def cfg(self, item):
value = self.base_config.get(item, '')
if not value:
return {'timezone': 0}.get(item, value)
else:
return value
def set_cfg(self, item, value):
cfg = self.base_config
cfg[item] = value
self.base_config = cfg

#通过这两个关于书籍的配置信息的函数,提供一些合理的初始化值
def book_cfg(self, item):
value = self.book_config.get(item, '')
if not value:
return {'type': 'epub', 'title': 'KindleEar', 'time_fmt': '%Y-%m-%d', 'oldest_article': 7,
'language': 'en'}.get(item, value)
else:
return value
def set_book_cfg(self, item, value):
cfg = self.book_config
cfg[item] = value
self.book_config = cfg

#自己直接所属的自定义RSS列表,返回[Recipe,]
def all_custom_rss(self):
return sorted(Recipe.select().where((Recipe.user == self.name) & (Recipe.type_ == 'custom')),
Expand Down Expand Up @@ -80,7 +96,7 @@ def get_cover_data(self):
data = b''
covers = self.covers or {}
order = covers.get('order', 'random')
idx = random.randint(0, 6) if (order == 'random') else tz_now(self.timezone).weekday()
idx = random.randint(0, 6) if (order == 'random') else self.local_time().weekday()
coverName = f'cover{idx}'
cover = covers.get(coverName, f'/images/{coverName}.jpg')
if cover.startswith('/images/'):
Expand Down Expand Up @@ -109,6 +125,29 @@ def get_send_mail_service(self):
else:
dbItem = KeUser.get_or_none(KeUser.name == adminName)
return dbItem.send_mail_service if dbItem else {}

#使用自身的密钥加密和解密字符串
def encrypt(self, txt):
return ke_encrypt((txt or ''), self.cfg('secret_key'))
def decrypt(self, txt):
return ke_decrypt((txt or ''), self.cfg('secret_key'))
def hash_text(self, txt):
return hashlib.md5((txt + self.cfg('secret_key')).encode('utf-8')).hexdigest()

#自定义字典的设置
def set_custom(self, item, value):
custom = self.custom
if value is None:
custom.pop(item, None)
else:
custom[item] = value
self.custom = custom
return self

#返回用户的本地时间,如果参数 fmt 非空,则返回字符串表达,否则返回datetime实例
def local_time(self, fmt=None):
tm = datetime.datetime.now(tz=datetime.timezone(datetime.timedelta(hours=self.cfg('timezone'))))
return tm.strftime(fmt) if fmt else tm

#用户的一些二进制内容,比如封面之类的
class UserBlob(MyBaseModel):
Expand All @@ -131,6 +170,7 @@ class Recipe(MyBaseModel):
user = CharField() #哪个账号创建的,和nosql一致,保存用户名
language = CharField(default='')
translator = JSONField(default=JSONField.dict_default) #用于自定义RSS的备份,实际使用的是BookedRecipe
tts = JSONField(default=JSONField.dict_default) #用于自定义RSS的备份,实际使用的是BookedRecipe
custom = JSONField(default=JSONField.dict_default) #留着扩展,避免后续一些小特性还需要升级数据表结构

#在程序内其他地方使用的id,在数据库内则使用 self.id
Expand Down Expand Up @@ -163,17 +203,18 @@ class BookedRecipe(MyBaseModel):
send_times = JSONField(default=JSONField.list_default)
time = DateTimeField(default=datetime.datetime.utcnow) #源被订阅的时间,用于排序
translator = JSONField(default=JSONField.dict_default)
tts = JSONField(default=JSONField.dict_default)
custom = JSONField(default=JSONField.dict_default) #留着扩展,避免后续一些小特性还需要升级数据表结构

@property
def password(self):
userInst = KeUser.get_or_none(KeUser.name == self.user)
return ke_decrypt(self.encrypted_pwd, userInst.secret_key if userInst else '')
dbItem = KeUser.get_or_none(KeUser.name == self.user)
return dbItem.decrypt(self.encrypted_pwd) if dbItem else ''

@password.setter
def password(self, pwd):
userInst = KeUser.get_or_none(KeUser.name == self.user)
self.encrypted_pwd = ke_encrypt(pwd, userInst.secret_key if userInst else '')
dbItem = KeUser.get_or_none(KeUser.name == self.user)
self.encrypted_pwd = dbItem.encrypt(pwd) if dbItem else ''

#书籍的推送历史记录
class DeliverLog(MyBaseModel):
Expand Down Expand Up @@ -206,6 +247,7 @@ class SharedRss(MyBaseModel):
last_subscribed_time = DateTimeField(default=datetime.datetime.utcnow, index=True)
invalid_report_days = IntegerField(default=0) #some one reported it is a invalid link
last_invalid_report_time = DateTimeField(default=datetime.datetime.utcnow) #a rss will be deleted after some days of reported_invalid
custom = JSONField(default=JSONField.dict_default)

#返回数据库中所有的分类
@classmethod
Expand Down
12 changes: 6 additions & 6 deletions application/back_end/send_mail_adpt.py
Expand Up @@ -6,7 +6,7 @@
#https://cloud.google.com/appengine/docs/standard/python3/reference/services/bundled/google/appengine/api/mail
#https://cloud.google.com/appengine/docs/standard/python3/services/mail
import os, datetime, zipfile, base64
from ..utils import local_time, ke_decrypt, str_to_bool
from ..utils import ke_decrypt, str_to_bool
from ..base_handler import save_delivery_log
from .db_models import KeUser

Expand Down Expand Up @@ -58,12 +58,12 @@ def avaliable_sm_services():
#attachment: 附件二进制内容,或元祖 (filename, content)
#fileWithTime: 发送的附件文件名是否附带当前时间
def send_to_kindle(user, title, attachment, fileWithTime=True):
lcTime = local_time('%Y-%m-%d_%H-%M', user.timezone)
lcTime = user.local_time('%Y-%m-%d_%H-%M')
subject = f"KindleEar {lcTime}"

if not isinstance(attachment, tuple):
lcTime = "({})".format(lcTime) if fileWithTime else ""
fileName = f"{title}{lcTime}.{user.book_type}"
fileName = f"{title}{lcTime}.{user.book_cfg('type')}"
attachment = (fileName, attachment)

if not isinstance(attachment, list):
Expand All @@ -72,7 +72,7 @@ def send_to_kindle(user, title, attachment, fileWithTime=True):
status = 'ok'
body = "Deliver from KindleEar"
try:
send_mail(user, user.kindle_email, subject, body, attachment)
send_mail(user, user.cfg('kindle_email'), subject, body, attachment)
except Exception as e:
status = str(e)
default_log.warning(f'Failed to send mail "{title}": {status}')
Expand All @@ -86,7 +86,7 @@ def send_mail(user, to, subject, body, attachments=None, html=None):
to = to.split(',')
sm_service = user.get_send_mail_service()
srv_type = sm_service.get('service', '')
data = {'sender': user.sender, 'to': to, 'subject': subject, 'body': body}
data = {'sender': user.cfg('sender'), 'to': to, 'subject': subject, 'body': body}
if attachments:
data['attachments'] = attachments
if html:
Expand All @@ -106,7 +106,7 @@ def send_mail(user, to, subject, body, attachments=None, html=None):
data['host'] = sm_service.get('host', '')
data['port'] = sm_service.get('port', 587)
data['username'] = sm_service.get('username', '')
data['password'] = ke_decrypt(sm_service.get('password', ''), user.secret_key)
data['password'] = user.decrypt(sm_service.get('password'))
smtp_send_mail(**data)
elif srv_type == 'local':
save_mail_to_local(sm_service.get('save_path', 'tests/debug_mail'), **data)
Expand Down
6 changes: 2 additions & 4 deletions application/base_handler.py
Expand Up @@ -7,7 +7,6 @@
from urllib.parse import urlparse
from flask import request, redirect, render_template, session, url_for
from .back_end.db_models import *
from .utils import local_time

#一些共同的工具函数,工具函数都是小写+下划线形式

Expand All @@ -34,13 +33,12 @@ def get_login_user():
def save_delivery_log(user, book, size, status='ok', to=None):
global default_log
name = user.name
to = to or user.kindle_email
tz = user.timezone
to = to or user.cfg('kindle_email')
if isinstance(to, list):
to = ','.join(to)

try:
DeliverLog.create(user=name, to=to, size=size, time_str=local_time(tz=tz),
DeliverLog.create(user=name, to=to, size=size, time_str=user.local_time("%Y-%m-%d %H:%M"),
datetime=datetime.datetime.utcnow(), book=book, status=status)
except Exception as e:
default_log.warning('DeliverLog failed to save: {}'.format(e))
12 changes: 6 additions & 6 deletions application/lib/build_ebook.py
Expand Up @@ -13,15 +13,15 @@
#从输入格式生成对应的输出格式
#recipes: 编译后的recipe,为一个列表
#user: KeUser对象
#output_fmt: 如果指定,则生成特定格式的书籍,否则使用user.book_type
#output_fmt: 如果指定,则生成特定格式的书籍,否则使用user.book_cfg('type')
#options: 额外的一些参数,为一个字典
# 如: options={'debug_pipeline': path, 'verbose': 1}
#返回电子书二进制内容
def recipes_to_ebook(recipes: list, user, options: dict=None, output_fmt: str=None):
if not isinstance(recipes, list):
recipes = [recipes]
output = io.BytesIO()
output_fmt=output_fmt if output_fmt else user.book_type
output_fmt=output_fmt if output_fmt else user.book_cfg('type')
plumber = Plumber(recipes, output, input_fmt='recipe', output_fmt=output_fmt)
plumber.merge_ui_recommendations(ke_opts(user, options))
plumber.run()
Expand All @@ -31,7 +31,7 @@ def recipes_to_ebook(recipes: list, user, options: dict=None, output_fmt: str=No
#urls: [(title, url),...] or [url,url,...]
#title: 书籍标题
#user: KeUser对象
#output_fmt: 如果指定,则生成特定格式的书籍,否则使用user.book_type
#output_fmt: 如果指定,则生成特定格式的书籍,否则使用user.book_cfg('type')
#options: 额外的一些参数,为一个字典
def urls_to_book(urls: list, title: str, user, options: dict=None, output_fmt: str=None):
if not isinstance(urls[0], (tuple, list)):
Expand All @@ -49,7 +49,7 @@ def urls_to_book(urls: list, title: str, user, options: dict=None, output_fmt: s

return recipes_to_ebook(ro, user, options, output_fmt)

#将一个html文件和其图像内容转换为一本电子书,返回电子书二进制内容,格式为user.book_type
#将一个html文件和其图像内容转换为一本电子书,返回电子书二进制内容,格式为user.book_cfg('type')
#html: html文本内容
#title: 书籍标题
#user: KeUser实例
Expand All @@ -59,7 +59,7 @@ def urls_to_book(urls: list, title: str, user, options: dict=None, output_fmt: s
def html_to_book(html: str, title: str, user, imgs: list=None, options: dict=None, output_fmt: str=None):
input_ = {'html': html, 'imgs': imgs, 'title': title}
output = io.BytesIO()
output_fmt=output_fmt if output_fmt else user.book_type
output_fmt=output_fmt if output_fmt else user.book_cfg('type')
plumber = Plumber(input_, output, input_fmt='html', output_fmt=output_fmt)
plumber.merge_ui_recommendations(ke_opts(user, options))
plumber.run()
Expand All @@ -71,7 +71,7 @@ def ke_opts(user, options=None):
if not isinstance(opt, dict):
opt = {}
opt.update(options or {})
opt.setdefault('output_profile', user.device)
opt.setdefault('output_profile', user.book_cfg('device'))
opt.setdefault('input_profile', 'kindle')
opt.setdefault('no_inline_toc', False)
opt.setdefault('epub_inline_toc', True)
Expand Down
Expand Up @@ -70,7 +70,7 @@ def convert(self, input_, opts, file_ext, log, output_dir, fs):
mi.author_sort = 'KindleEar'
mi.authors = ['KindleEar']
mi.publication_type = f'book:book:{title}'
mi.timestamp = datetime.datetime.utcnow() + datetime.timedelta(hours=user.timezone)
mi.timestamp = user.local_time()
opf = OPFCreator(fs.path, mi, fs)

#manifest 资源列表
Expand Down
Expand Up @@ -298,8 +298,8 @@ def build_meta(self, recipe1, onlyRecipe):
mi = MetaInformation(title, ['KindleEar'])
mi.publisher = 'KindleEar'
#修正Kindle固件5.9.x将作者显示为日期的BUG
authorFmt = self.user.author_format
now = datetime.datetime.utcnow() + datetime.timedelta(hours=self.user.timezone)
authorFmt = self.user.book_cfg('author_fmt')
now = self.user.local_time()
if authorFmt:
snow = now.strftime(authorFmt)
mi.author_sort = snow
Expand Down Expand Up @@ -329,7 +329,7 @@ def build_meta(self, recipe1, onlyRecipe):
desc = desc.decode('utf-8', 'replace')
mi.comments = (_('Articles in this issue:') + '\n' + '\n'.join(article_titles)) + '\n\n' + desc

language = canonicalize_lang(recipe1.language if onlyRecipe else self.user.book_language)
language = canonicalize_lang(recipe1.language if onlyRecipe else self.user.book_cfg('language'))
if language is not None:
mi.language = language
mi.pubdate = pdate
Expand Down
18 changes: 16 additions & 2 deletions application/lib/calibre/web/feeds/news.py
Expand Up @@ -8,7 +8,7 @@
__docformat__ = "restructuredtext en"


import io, os, re, sys, time, datetime, traceback
import io, os, re, sys, time, datetime, traceback, base64
from collections import defaultdict, namedtuple
from urllib.parse import urlparse, urlsplit, quote, urljoin
from urllib.error import HTTPError, URLError
Expand Down Expand Up @@ -1042,7 +1042,7 @@ def _postprocess_html(self, soup, first_fetch, job_info):
del img['srcset']

#如果需要,去掉正文中的超链接(使用斜体下划线标识),以避免误触
remove_hyperlinks = self.user.remove_hyperlinks
remove_hyperlinks = self.user.book_cfg('rm_links')
if remove_hyperlinks in ('text', 'all'):
for a_ in soup.find_all('a'):
a_.name = 'i'
Expand Down Expand Up @@ -1115,6 +1115,7 @@ def append_share_links(self, soup, url):
ashare.string = _('Open in browser')
aTags.append(ashare)

#将上面创建的链接都添加到body
bodyTag = soup.find("body")
for idx, a in enumerate(aTags):
bodyTag.append(a)
Expand All @@ -1123,6 +1124,19 @@ def append_share_links(self, soup, url):
span.string = ' | '
bodyTag.append(span)

#如果有需要二维码功能,最后添加
if shareLinks.get('Qrcode'):
import qrcode
qr = qrcode.QRCode(error_correction=qrcode.constants.ERROR_CORRECT_L)
qr.add_data(url)
qr.make(fit=True)
img = qr.make_image()
buffer = io.BytesIO()
img.save(buffer, 'PNG')
qrBase64 = base64.b64encode(buffer.getvalue()).decode('utf-8')
bodyTag.append(soup.new_tag('br'))
bodyTag.append(soup.new_tag('img', src=f"data:image/png;base64,{qrBase64}"))

#生成保存内容或分享文章链接的KindleEar调用链接
def make_share_link(self, shareType, user, url, soup):
share_key = user.share_links.get('key', '')
Expand Down
11 changes: 6 additions & 5 deletions application/lib/ebook_translator/html_translator.py
Expand Up @@ -55,11 +55,11 @@ def translate_text(self, data):
item['error'] = ''
item['translated'] = ''
if text:
if 1:
try:
item['translated'] = self.translator.translate(text)
#except Exception as e:
#default_log.warning('translate_text() failed: ' + str(e))
#item['error'] = str(e)
except Exception as e:
default_log.warning('translate_text failed: ' + str(e))
item['error'] = str(e)
else:
item['error'] = _('The input text is empty')
ret.append(item)
Expand Down Expand Up @@ -111,7 +111,8 @@ def _extract(tag):
for child in tag.find_all(recursive=False):
if _contains_text(child):
text = str(child).strip()
if text and child.name not in ('pre', 'code', 'abbr'):
if text and child.name not in ('pre', 'code', 'abbr', 'style', 'script', 'textarea',
'input', 'select', 'link', 'img', 'option', 'datalist'):
elements.append((child, text))
else:
_extract(child)
Expand Down
4 changes: 4 additions & 0 deletions application/lib/ebook_tts/__init__.py
@@ -0,0 +1,4 @@
#!/usr/bin/env python3
# -*- coding:utf-8 -*-
from .engines import *
from .html_audiolator import get_tts_engines, HtmlAudiolator

0 comments on commit e3fa272

Please sign in to comment.