diff --git a/api_v2.py b/api_v2.py index ef6b2dd..2c728a0 100644 --- a/api_v2.py +++ b/api_v2.py @@ -172,6 +172,82 @@ def query_one(conn, sql, params=None): return rows[0] if rows else None +def upsert_recommended_book(conn, values): + """按 book_id 更新推荐缓存,避免同一本书每次推荐都新增一行。""" + if not conn: + return + columns = ( + "book_id, title, author, cover, intro, category, reason, reading_count, " + "new_rating, new_rating_count, rating_title, deep_link, search_idx, price, " + "pay_type, book_type, batch_id" + ) + try: + with conn.cursor() as cur: + cur.execute("SELECT id FROM recommended_books WHERE book_id=%s ORDER BY id LIMIT 1", (values[0],)) + row = cur.fetchone() + if row: + cur.execute( + "UPDATE recommended_books SET title=%s, author=%s, cover=%s, intro=%s, category=%s, " + "reason=%s, reading_count=%s, new_rating=%s, new_rating_count=%s, rating_title=%s, " + "deep_link=%s, search_idx=%s, price=%s, pay_type=%s, book_type=%s, batch_id=%s, " + "updated_at=NOW() WHERE id=%s", + (*values[1:], row[0]), + ) + else: + cur.execute( + f"INSERT INTO recommended_books ({columns}) VALUES ({','.join(['%s'] * 17)})", + values, + ) + cur.execute( + "INSERT INTO content_logs " + "(event_type, batch_id, book_id, search_idx) VALUES ('recommendation',%s,%s,%s)", + (values[16], values[0], values[12] or 0), + ) + conn.commit() + except Exception: + pass + + +def upsert_search_result(conn, session_id, values): + """按 keyword + book_id 更新搜索缓存,避免重复搜索产生重复行。""" + if not conn or not session_id: + return + keyword, book_id = values[0], values[1] + try: + with conn.cursor() as cur: + cur.execute( + "SELECT id FROM search_results WHERE keyword=%s AND book_id=%s ORDER BY id LIMIT 1", + (keyword, book_id), + ) + row = cur.fetchone() + if row: + cur.execute( + "UPDATE search_results SET session_id=%s, title=%s, author=%s, cover=%s, intro=%s, " + "publisher=%s, category=%s, reading_count=%s, new_rating=%s, new_rating_count=%s, " + "rating_title=%s, deep_link=%s, pay_type=%s, price=%s, soldout=%s, search_idx=%s, " + "result_scope=%s, result_title=%s, scope_count=%s, current_count=%s, updated_at=NOW() " + "WHERE id=%s", + (session_id, *values[2:], row[0]), + ) + else: + cur.execute( + "INSERT INTO search_results " + "(session_id, keyword, book_id, title, author, cover, intro, publisher, category, " + "reading_count, new_rating, new_rating_count, rating_title, deep_link, pay_type, price, " + "soldout, search_idx, result_scope, result_title, scope_count, current_count) " + f"VALUES ({','.join(['%s'] * 22)})", + (session_id, *values), + ) + cur.execute( + "INSERT INTO content_logs " + "(event_type, session_id, keyword, book_id, search_idx) VALUES ('search',%s,%s,%s,%s)", + (session_id, keyword, book_id, values[16] or 0), + ) + conn.commit() + except Exception: + pass + + # ====================================================================== # 格式化与展示辅助 # ====================================================================== @@ -1368,14 +1444,41 @@ def api_v2_archives(conn): def api_v2_recommend(conn): """为你推荐(实时)。Query: maxIdx""" max_idx = request.args.get("maxIdx", type=int, default=0) + refresh = request.args.get("refresh", "0") == "1" try: - data = weapi("/book/recommend", ttl=600, count=12, maxIdx=max_idx) + data = weapi("/book/recommend", ttl=0 if refresh else 600, count=12, maxIdx=max_idx) except WeReadError as e: return jsonify({"error": str(e)}), 502 books = [] - for book in data.get("books") or []: + batch_id = datetime.now().strftime("%Y%m%d%H%M%S") + raw_books = data.get("books") or [] + + def enrich_recommendation(book): + info = weapi_safe("/book/info", ttl=1800, bookId=str(book.get("bookId") or "")) or {} + merged = dict(book) + for key in ("cover", "newRating", "newRatingCount", "newRatingDetail", "intro", "category", "deepLink"): + if not merged.get(key) and info.get(key): + merged[key] = info[key] + return merged + + with ThreadPoolExecutor(max_workers=6) as pool: + enriched_books = list(pool.map(enrich_recommendation, raw_books)) + + for book in enriched_books: rating_detail = book.get("newRatingDetail") or {} + if conn: + upsert_recommended_book( + conn, + ( + book.get("bookId", ""), book.get("title", ""), book.get("author", ""), + book.get("cover", ""), book.get("intro", ""), book.get("category", ""), + book.get("reason", ""), book.get("readingCount", 0), book.get("newRating", 0), + book.get("newRatingCount", 0), (rating_detail or {}).get("title", ""), + book.get("deepLink", ""), book.get("searchIdx", 0), book.get("price"), + book.get("payType"), book.get("type", 0), batch_id, + ), + ) books.append( { "id": str(book.get("bookId") or ""), @@ -1436,16 +1539,28 @@ def api_v2_search(conn): return jsonify({"error": str(e), "keyword": keyword, "scope": scope_name}), 502 books, authors, lists, groups = [], [], [], [] + session_id = None + if conn: + try: + with conn.cursor() as cur: + cur.execute( + "INSERT INTO search_sessions (sid, keyword, scope, has_more, max_idx) VALUES (%s,%s,%s,%s,%s)", + (data.get("sid", ""), keyword, scope, data.get("hasMore", 0), 0), + ) + session_id = cur.lastrowid + except Exception: + session_id = None seen_books, seen_authors, seen_groups = set(), set(), set() for group in data.get("results") or []: title = group.get("title") or "" gkey = (title, group.get("scope"), group.get("currentCount")) - if gkey in seen_groups: - continue - seen_groups.add(gkey) + if gkey not in seen_groups: + seen_groups.add(gkey) + if title: + groups.append({"title": title, "scope": group.get("scope"), "count": group.get("scopeCount") or 0}) items = group.get("books") or [] - if title: - groups.append({"title": title, "scope": group.get("scope"), "count": group.get("scopeCount") or 0}) + if title == "作者" and not items and group.get("scopeCount"): + authors.append({"name": keyword, "count": group.get("scopeCount") or 0}) for item in items: bi = item.get("bookInfo") or item.get("book") or {} bid = str(bi.get("bookId") or "") @@ -1468,13 +1583,26 @@ def api_v2_search(conn): "short": short_title(bi.get("title")), "desc": bi.get("intro") or "", "category": bi.get("category") or "", - "score": round(float(item.get("newRating") or 0) / 100, 1) if item.get("newRating") else None, + "score": round(float(bi.get("newRating") or 0) / 100, 1) if bi.get("newRating") else None, "reading_count": item.get("readingCount") or 0, "deep_link": bi.get("deepLink") or "", "group": title, "status": "wish", "progress": 0, } + if session_id: + upsert_search_result( + conn, + session_id, + ( + keyword, bid, entry["title"], entry["author"], bi.get("cover", ""), + bi.get("intro", ""), bi.get("publisher", ""), entry["category"], + item.get("readingCount", 0), bi.get("newRating", 0), bi.get("newRatingCount", 0), + (bi.get("newRatingDetail") or {}).get("title", ""), entry["deep_link"], + bi.get("payType"), bi.get("price"), bi.get("soldout", 0), item.get("searchIdx", 0), + group.get("scope"), title, group.get("scopeCount") or 0, group.get("currentCount") or 0, + ), + ) if title in ("书单", "书单合集"): lists.append({**entry, "name": entry["title"], "source": title, "ids": []}) else: diff --git a/app.py b/app.py index 4a99cb2..5cfdc84 100644 --- a/app.py +++ b/app.py @@ -35,7 +35,7 @@ try: _ssl_context = ssl.create_default_context(cafile=certifi.where()) except ImportError: _ssl_context = ssl.create_default_context() -from flask import Flask, jsonify, render_template, request +from flask import Flask, g, jsonify, render_template, request from api_v2 import v2_bp @@ -61,6 +61,60 @@ MYSQL_CONFIG = { "cursorclass": pymysql.cursors.DictCursor, # 使用字典游标,方便按字段名取值 } + +# ====================================================================== +# 网站操作日志 +# ====================================================================== + +@app.before_request +def start_operation_log_timer(): + """记录请求开始时间,供 after_request 计算接口耗时。""" + g.operation_started_at = time.perf_counter() + + +@app.after_request +def write_operation_log(response): + """将 API 请求写入 operation_logs;日志失败不影响业务响应。""" + if not request.path.startswith("/api/"): + return response + + try: + started_at = getattr(g, "operation_started_at", time.perf_counter()) + duration_ms = max(0, int((time.perf_counter() - started_at) * 1000)) + path_parts = [part for part in request.path.split("/") if part] + action = "/".join(path_parts[-2:]) if len(path_parts) >= 2 else request.path + target_type = path_parts[-2] if len(path_parts) >= 2 else None + target_id = path_parts[-1] if len(path_parts) >= 2 and path_parts[-1] != target_type else None + detail = {"query": request.args.to_dict(flat=True)} + conn = pymysql.connect(**MYSQL_CONFIG) + try: + with conn.cursor() as cur: + cur.execute( + "INSERT INTO operation_logs " + "(operation_type, action, target_type, target_id, keyword, request_path, " + "request_method, status_code, duration_ms, detail_json) " + "VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)", + ( + "api_request", + action, + target_type, + target_id, + request.args.get("keyword"), + request.path, + request.method, + response.status_code, + duration_ms, + json.dumps(detail, ensure_ascii=False), + ), + ) + conn.commit() + finally: + conn.close() + except Exception: + # 日志属于旁路能力,数据库或表异常时不能影响正常页面/API 返回。 + pass + return response + # ====================================================================== # 微信读书 API 配置 # ====================================================================== diff --git a/static/js/app.js b/static/js/app.js index 42275dc..ef54aab 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -19,6 +19,8 @@ const SHELF_PAGE = 48; /* ------------------------------- 运行时状态 ------------------------------ */ let page = 'overview', shelfTab = 'all', discoverTab = 'books', searchQuery = '', period = 'week'; +let searchComposing = false, discoverSearchTimer = null; +let recommendOffset = 0; let noteBook = 'all', noteType = 'all', goalFilter = 'all'; let detailBook = null, bookTab = 'info', reviewFilter = 0, selectedChapter = 0; let shelfLimit = SHELF_PAGE, chapterLimit = 30, searchScope = 'store', notesLimit = 30; @@ -81,7 +83,8 @@ $('#theme').onclick = () => { /* 封面:优先显示微信读书返回的真实封面,加载失败时回退到示意封面 */ function cover(book, mini = false) { const short = book.short || book.title || ''; - const coverUrl = book.cover_url || (typeof book.cover === 'string' && /^https?:\/\//i.test(book.cover) ? book.cover : ''); + const rawCoverUrl = book.cover_url || (typeof book.cover === 'string' && /^https?:\/\//i.test(book.cover) ? book.cover : ''); + const coverUrl = rawCoverUrl.replace(/\/(?:t6_|s_)/, '/t8_'); const coverClass = typeof book.cover === 'string' && !/^https?:\/\//i.test(book.cover) ? book.cover : ''; const fallback = mini ? `` @@ -91,7 +94,7 @@ function cover(book, mini = false) { return `${esc(book.title)}`; } function scoreText(book) { - return book.score ? `评分 ${Math.round(Number(book.score) * 10)}/100` : '暂无评分'; + return book.score ? `评分 ${(Number(book.score) * 10).toFixed(2)}%` : '暂无评分'; } function stateText(book) { if (book.status === 'done') return '已读完'; @@ -143,13 +146,35 @@ async function ensureShelf(force = false) { if (loadState.shelf === 'loading') return; if (shelfData && !force) return; loadState.shelf = 'loading'; loadError.shelf = ''; - if (['shelf', 'overview', 'goals'].includes(page)) renderPage(); + if (['shelf', 'overview', 'goals', 'discover'].includes(page)) renderPage(); try { - const data = await WeApi.shelf('all', { progress: true }); + // 首屏只读取书架基础数据,避免等待逐本补拉进度和评分。 + const data = await WeApi.shelf('all', { progress: false }); shelfData = data; books = data.books || []; booklists = (data.lists || []).map(list => ({ ...list, description: list.description || `我的书单 · ${list.book_count} 本` })); loadState.shelf = 'ready'; + const localDiscoverKey = `${searchScope}:books:${searchQuery}`; + if (!searchQuery) { + discoverData[localDiscoverKey] = { local: true, books, authors: [], lists: booklists }; + loadState.discover[localDiscoverKey] = 'ready'; + } + if (['shelf', 'overview', 'goals', 'discover'].includes(page)) renderPage(); + + // 后台补充评分和阅读进度,完成后静默刷新,不阻塞首屏。 + WeApi.shelf('all', { progress: true }).then(enriched => { + shelfData = enriched; + books = enriched.books || books; + if (enriched.lists) { + booklists = enriched.lists.map(list => ({ ...list, description: list.description || `我的书单 · ${list.book_count} 本` })); + } + if (!searchQuery) { + discoverData[localDiscoverKey] = { local: true, books, authors: [], lists: booklists }; + } + if (['shelf', 'overview', 'goals', 'discover'].includes(page)) renderPage(); + }).catch(() => { + // 评分/进度补充失败时保留已展示的基础书架数据。 + }); } catch (error) { loadError.shelf = error.message; loadState.shelf = 'error'; } @@ -207,17 +232,17 @@ function discoverKey() { if (discoverTab === 'lists') return `${searchScope}:lists`; return `${searchScope}:${discoverTab}:${searchQuery}`; } -async function ensureDiscover(force = false) { +async function ensureDiscover(force = false, refresh = false) { const key = discoverKey(); if (loadState.discover[key] === 'loading') return; if (discoverData[key] && !force) return; loadState.discover[key] = 'loading'; loadError.discover[key] = ''; if (page === 'discover') renderPage(); try { - if (searchScope === 'shelf' || (!searchQuery && (discoverTab === 'books' || discoverTab === 'all'))) { + if (searchScope === 'shelf') { discoverData[key] = { local: true, books: books, authors: [], lists: [] }; } else if (discoverTab === 'recommend') { - discoverData[key] = await WeApi.recommend(0); + discoverData[key] = await WeApi.recommend(recommendOffset, refresh); } else { discoverData[key] = await WeApi.search(searchQuery || '文学', discoverTab); } @@ -458,6 +483,11 @@ function renderDiscover() { const options = [['books', '书籍'], ['lists', '书单'], ['recommend', '为你推荐'], ['all', '综合'], ['author', '作者'], ['novel', '网文'], ['audio', '听书'], ['fulltext', '全文'], ['account', '公众号'], ['article', '文章']]; if (!shelfData) ensureShelf(); const key = discoverKey(); + // 默认书籍页直接使用已加载的本地书架,不等待远程书城搜索。 + if (!discoverData[key] && !searchQuery && searchScope === 'shelf') { + discoverData[key] = { local: true, books, authors: [], lists: booklists }; + loadState.discover[key] = 'ready'; + } const payload = discoverData[key]; let content = ''; if (loadError.discover[key]) content = statusBox('error', '暂时无法获取数据', loadError.discover[key], 'retry-discover'); @@ -465,10 +495,10 @@ function renderDiscover() { else if (discoverTab === 'recommend') { const list = payload.books || []; const prefer = (dashboardData[period] && dashboardData[period].prefer_words.category) || '你的阅读偏好'; - content = `
PICKED FOR YOUR NEXT CHAPTER

读过的书,会带你遇见下一本。

${esc(prefer)} · 微信读书实时推荐

${list.map(recommendCard).join('') || '
接口暂时没有返回推荐书籍。
'}
`; + content = `
PICKED FOR YOUR NEXT CHAPTER

读过的书,会带你遇见下一本。

${esc(prefer)} · 微信读书实时推荐

${list.map(recommendCard).join('') || '
接口暂时没有返回推荐书籍。
'}
`; } else if (discoverTab === 'lists') { - const lists = (payload.lists && payload.lists.length) ? payload.lists : booklists; - content = renderLists(lists.map(list => ({ ...list, description: list.description || `收录 ${list.book_count || (list.ids || []).length} 本`, source: list.source || '我的书单' }))) + (payload.lists && payload.lists.length ? '' : '

书城书单搜索接口暂未返回结果,以上为你的书架书单。

'); + const lists = searchScope === 'shelf' ? booklists : (payload.lists || []); + content = renderLists(lists.map(list => ({ ...list, description: list.description || `收录 ${list.book_count || (list.ids || []).length} 本`, source: list.source || (searchScope === 'shelf' ? '我的书单' : '腾讯书城') }))) + (!lists.length && searchScope !== 'shelf' ? '

腾讯书城返回了书单数量,但当前接口没有返回具体书单条目。

' : ''); } else if (discoverTab === 'author') { const authors = payload.authors || []; content = `
${authors.map(author => `
${esc(author.name.slice(0, 1))}
${esc(author.name)}

作者 · 书城检索

`).join('') || '
没有匹配的作者,换个关键词试试。
'}
`; @@ -480,7 +510,8 @@ function renderDiscover() { : `
「${esc(names[discoverTab])}」搜索:接口当前没有返回条目,可切换其他类型或关键词。
`; } else if (discoverTab === 'all') { const list = payload.books || []; - content = `

书籍

${list.slice(0, 8).map(searchCard).join('') || '
没有匹配的书籍。
'}

书单

${renderLists(booklists.slice(0, 3))}`; + const remoteLists = payload.lists || []; + content = `

书籍

${list.slice(0, 8).map(searchCard).join('') || '
没有匹配的书籍。
'}

书单

${renderLists(remoteLists)}${!remoteLists.length ? '

腾讯书城未返回具体书单条目。

' : ''}`; } else { const list = payload.books || []; const local = list.filter(book => !searchQuery || `${book.title}${book.author}${book.category}`.toLowerCase().includes(searchQuery.toLowerCase())); @@ -514,7 +545,7 @@ function renderBookDetail() { const progress = data.progress.progress; const state = progress >= 100 ? '已读完' : progress > 0 ? `已读 ${progress}%` : '尚未开始阅读'; const readTimeText = data.progress.record_reading_time ? ` · 累计 ${esc(data.progress.record_reading_time_str)}` : ''; - showDetail('书籍详情', `
${cover(info)}
READING SPACE / BOOK

${esc(info.title)}

${esc(info.author)}${info.translator ? ' · 译者 ' + esc(info.translator) : ''}

${info.rating ? `评分 ${Math.round(info.rating)}/100 · ${info.rating_count} 人评价` : '暂无评分'}

${state}${readTimeText}

${info.deep_link ? ` 在微信读书中打开 ↗` : ''}
${[['info', '简介'], ['chapters', '目录'], ['notes', '我的笔记'], ['popular', '热门划线'], ['reviews', '读者点评'], ['similar', '相似推荐']].map(([tab, label]) => ``).join('')}
${renderBookTab(data)}
`); + showDetail('书籍详情', `
${cover(info)}
READING SPACE / BOOK

${esc(info.title)}

${esc(info.author)}${info.translator ? ' · 译者 ' + esc(info.translator) : ''}

${info.rating ? `评分 ${(Number(info.rating) / 10).toFixed(2)}% · ${info.rating_count} 人评价` : '暂无评分'}

${state}${readTimeText}

${info.deep_link ? ` 在微信读书中打开 ↗` : ''}
${[['info', '简介'], ['chapters', '目录'], ['notes', '我的笔记'], ['popular', '热门划线'], ['reviews', '读者点评'], ['similar', '相似推荐']].map(([tab, label]) => ``).join('')}
${renderBookTab(data)}
`); } function renderBookTab(data) { const info = data.book; @@ -592,6 +623,7 @@ function handleAction(button) { case 'shelf-more': shelfLimit += SHELF_PAGE; renderPage(); break; case 'period': period = id; renderPage(); break; case 'discover-tab': discoverTab = id; renderPage(); break; + case 'refresh-recommend': recommendOffset += 12; ensureDiscover(true, true); break; case 'retry-overview': ensureDashboard(period, true); break; case 'retry-shelf': ensureShelf(true); break; case 'retry-notes': if (!notebooksData) ensureNotebooks(true); else ensureNotes(noteBook, true); break; @@ -642,15 +674,37 @@ $('#goal-start').onchange = () => { $('#goal-end').min = $('#goal-start').value; $('#goal-name').oninput = () => $('#goal-name').setCustomValidity(''); $('#goal-form').onsubmit = saveGoal; $('#picker-search').oninput = renderPicker; -$('#global-search').oninput = event => { - searchQuery = event.target.value.trim(); +function applyGlobalSearch(value) { + searchQuery = value; if (!['books', 'all', 'author', 'novel', 'audio', 'fulltext', 'account', 'article'].includes(discoverTab)) discoverTab = 'books'; navigate('discover'); +} +$('#global-search').addEventListener('compositionstart', () => { searchComposing = true; }); +$('#global-search').addEventListener('compositionend', event => { + searchComposing = false; + applyGlobalSearch(event.target.value.trim()); +}); +$('#global-search').oninput = event => { + if (searchComposing || event.isComposing) return; + clearTimeout(discoverSearchTimer); + discoverSearchTimer = setTimeout(() => applyGlobalSearch(event.target.value.trim()), 300); }; document.addEventListener('input', event => { if (event.target.id === 'discover-search') { - const position = event.target.selectionStart; searchQuery = event.target.value; $('#global-search').value = searchQuery; renderPage(); - $('#discover-search').focus(); $('#discover-search').setSelectionRange(position, position); + if (event.isComposing || searchComposing) return; + const value = event.target.value; + $('#global-search').value = value; + clearTimeout(discoverSearchTimer); + discoverSearchTimer = setTimeout(() => applyGlobalSearch(value), 300); + } +}); +document.addEventListener('compositionstart', event => { + if (event.target.id === 'discover-search') searchComposing = true; +}); +document.addEventListener('compositionend', event => { + if (event.target.id === 'discover-search') { + searchComposing = false; + applyGlobalSearch(event.target.value.trim()); } }); document.addEventListener('keydown', event => { if ((event.metaKey || event.ctrlKey) && event.key === 'k') { event.preventDefault(); $('#global-search').focus(); } }); diff --git a/static/js/weapi.js b/static/js/weapi.js index 3f7a98d..80c00e4 100644 --- a/static/js/weapi.js +++ b/static/js/weapi.js @@ -112,8 +112,8 @@ return request('/archives'); }, - recommend(maxIdx) { - return request('/recommend', { maxIdx: maxIdx || 0 }); + recommend(maxIdx, refresh = false) { + return request('/recommend', { maxIdx: maxIdx || 0, refresh: refresh ? 1 : 0 }); }, search(keyword, scope) {