Stream reader results and navigate with cursors [skip ci]
This commit is contained in:
+18
-6
@@ -1,6 +1,6 @@
|
||||
# 论文只读 HTTP 接口
|
||||
|
||||
状态:既有论文库已完成 PostgreSQL 迁移与独立验收。此前 SQLite 实现方案已撤回,未部署;Foundry Core 的 codex/research-api 分支正在按实际 PostgreSQL schema 联调。本任务包括后端实现与联调,不把接口开发留给用户。
|
||||
状态:既有论文库已完成 PostgreSQL 迁移与独立验收。此前 SQLite 实现方案已撤回,未部署;Foundry Core 的初版论文 API 已合并 main;本轮优化改用游标契约。本任务包括后端实现与联调,不把接口开发留给用户。
|
||||
|
||||
## 地址与配置
|
||||
|
||||
@@ -14,13 +14,16 @@ PAPER_API_ORIGIN=https://api.foundry.entroia.com/projects/thesisly/research/
|
||||
|
||||
## 列表
|
||||
|
||||
GET {PAPER_API_ORIGIN}/papers?q=&searchIn=text&topic=&sort=recent&page=1&pageSize=8
|
||||
GET {PAPER_API_ORIGIN}/papers?q=&searchIn=text&topic=&sort=recent&cursor=&pageSize=8
|
||||
|
||||
- q:最多200个Unicode字符、20个词。searchIn=text时检索标题/摘要,searchIn=author时检索显示作者名。不会把用户输入作为任意SQL或FTS操作符。
|
||||
- topic:精确arxiv:category值。
|
||||
- sort:recent是稳定论文ID降序,即收录顺序,**不是发表日期排序**;title使用现有title_key索引。
|
||||
- page:1–1000;pageSize:1–50,前端当前为8。前端与此上限一致;结果超过8,000条时明确提示缩小搜索或领域范围,不生成不可访问的下一页链接。超出实际末页的页面地址会跳转至当前条件下的末页。
|
||||
- 返回{items:Paper[],total,topics,page,pageSize},同一只读事务内计数及取页;无效、重复、未知参数返回400。
|
||||
- pageSize:1–50,前端当前为8。每次取9个位置,显示8篇,以额外一条判断是否有下一页。
|
||||
- cursor:首屏省略;后续原样使用返回的 nextCursor / previousCursor。游标绑定关键词、范围、领域、排序和每页大小;修改筛选必须清除游标。空字符串表示没有该方向的下一页。
|
||||
- 返回 `{items:Paper[],nextCursor:string,previousCursor:string,topics:string[],pageSize:number}`。不再返回 total/page,也不接受旧 page 参数;前后端需协调切换。列表不执行全库或搜索精确计数,不依赖总页数或8,000条浏览上限。
|
||||
- recent 使用 ID 降序;title 使用存储的 title_key、ID 升序以区分同标题记录。每次请求是一个只读快照,跨页期间新增/删除/修改记录可能改变结果,不承诺整个浏览过程冻结语料库。
|
||||
- 无效、重复、未知参数返回400;游标不是账户凭据。手工修改或跨筛选复用游标需要清除筛选重新浏览。
|
||||
|
||||
## 论文
|
||||
|
||||
@@ -32,7 +35,9 @@ source_records按paper_id关联;作者兼容[family,given,suffix]数组与字
|
||||
|
||||
已核对实际 PostgreSQL schema、编码标记和索引,并在服务器创建专用 SELECT 账号及受保护连接配置。已通过 SSH 回环隧道验证真实全库首页和详情(桌面/手机无页面异常和横向溢出),非常宽泛查询可能超出8秒预算;临时 Core 不是正式部署。不部署 SQLite sidecar,不复制整库,不从浏览器持有数据库连接串。
|
||||
|
||||
最多两个并行查询、8秒查询预算;繁忙返回429,超时/存储异常明确报错。已补建作者和领域索引,并局部关闭 JIT、调整过滤取页顺序。实测作者搜索146条、cs.AR领域9457条、CartoonGAN搜索3条、多词全文检索8653条均返回200;非常宽泛的检索仍可能超出8秒预算,这是当前已知的性能限制。语料库是共享公开数据;账户与未来私有收藏仍须按项目隔离。
|
||||
默认排序的纯领域查询直接沿现有领域/论文索引取页;全文查询先在游标附近512的 ID 区间找一页,有少量匹配但不足一页时扩展一次到4096,仍不足9个位置才丢弃局部候选、执行完整索引检索,保证不截断较旧或稀疏结果。标题排序仍使用完整候选检索,宽泛条件仍可能超时。
|
||||
|
||||
最多两个并行查询、8秒查询预算;繁忙返回429,超时/存储异常明确报错。已补建作者和领域索引,并局部关闭 JIT、调整过滤取页顺序。初版计数接口曾验证作者、领域和多词全文检索;非常宽泛的检索仍可能超出8秒预算,这是当前已知的性能限制。语料库是共享公开数据;账户与未来私有收藏仍须按项目隔离。
|
||||
|
||||
## 联调前置
|
||||
|
||||
@@ -46,4 +51,11 @@ source_records按paper_id关联;作者兼容[family,given,suffix]数组与字
|
||||
|
||||
## 查询失败后的恢复
|
||||
|
||||
列表将超时、繁忙、无效条件和服务不可用作为可恢复状态:保留关键词/搜索范围/领域,不显示未知总数和分页,不把错误显示成0条结果,也不退回样本。用户可直接修改关键词或重试。真实库已验证graph超时后修改为CartoonGAN恢复3条;隔离浏览器和匿名HTTP传输用例覆盖了错误分类、表单保留及异常响应处理。
|
||||
列表将超时、繁忙、无效条件和服务不可用作为可恢复状态:保留关键词/搜索范围/领域,不显示总数或可用分页,不把错误显示成0条结果,也不退回样本。用户可直接修改关键词或重试。真实库已验证graph超时后修改为CartoonGAN恢复3条;隔离浏览器和匿名HTTP传输用例覆盖了错误分类、表单保留及异常响应处理。
|
||||
|
||||
|
||||
## 页面请求与后续优化
|
||||
|
||||
首页标题、搜索和排序先渲染,论文结果与领域列表通过 Suspense 后续载入,并复用同一个列表请求。搜索/排序使用 Next Form 站内导航,保留根布局和登录状态;列表中的领域、文章和分页链接关闭自动预取,限制链接预取带来的后台请求;本轮未执行生产 build,未测量生产预取收益。查询仍有明确的等待、失败、空结果三种状态。
|
||||
|
||||
下一阶段应分别测量:登录回调到可操作页面的耗时、窗口焦点触发的会话检查频率、文章 metadata 与正文的重复读取。可采用会话请求合并/焦点检查冷却、请求内文章读取去重,以及公开列表的短时有界缓存;账户校验和权限不复用公开缓存。总量若有产品需要,独立后台统计并标注统计时间,不进入阅读和搜索的关键路径。优先实测这些站内改动,不调整 Traefik。
|
||||
|
||||
+32
-14
@@ -1,24 +1,42 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import Form from "next/form";
|
||||
import { Suspense } from "react";
|
||||
import { ArrowRight, ArrowUpRight, Search, X } from "lucide-react";
|
||||
import { listPapers, normalizeFilters, paperDate, MAX_PAPER_PAGE, type Filters } from "@/lib/papers";
|
||||
import { listPapers, normalizeFilters, paperDate, type Filters } from "@/lib/papers";
|
||||
import { Button } from "@/components/ui/button";
|
||||
export default async function Home({ searchParams }: { searchParams: Promise<Record<string,string | string[] | undefined>> }) {
|
||||
const f = normalizeFilters(await searchParams), data = await listPapers(f);
|
||||
const pages = data.total === null ? 0 : Math.min(MAX_PAPER_PAGE, Math.ceil(data.total / data.pageSize));
|
||||
import { SearchSubmit } from "@/components/search-submit";
|
||||
|
||||
type Result = ReturnType<typeof listPapers>;
|
||||
function href(f: Filters, changes: Filters) { return "/?" + new URLSearchParams(Object.entries({ ...f, ...changes }).filter(([,v]) => typeof v === "string" && !!v) as [string,string][]).toString(); }
|
||||
function Pending() { return <div className="empty-state" role="status" aria-live="polite"><p>正在读取论文索引…</p></div>; }
|
||||
async function Topics({ result, f }: { result: Result; f: Filters }) {
|
||||
const data = await result;
|
||||
return <aside className="index-aside"><h2>研究领域</h2><div className="topic-list" role="region" aria-label="研究领域分类" tabIndex={0}><Link prefetch={false} className={!f.topic ? "topic active" : "topic"} href={href(f, { topic: "", cursor: "" })}>全部领域 <span>↗</span></Link>{data.topics.map(t => <Link prefetch={false} key={t} className={f.topic === t ? "topic active" : "topic"} href={href(f, { topic: t, cursor: "" })}>{t}</Link>)}</div><p className="aside-note">保留原始标题与摘要。<br />不把自动生成的解读当成论文结论。</p></aside>;
|
||||
}
|
||||
async function Results({ result, f }: { result: Result; f: Filters }) {
|
||||
const data = await result;
|
||||
const issue = data.mode === "error" ? {
|
||||
timeout: ["这次检索耗时较长", "试试更具体的关键词,或清除筛选后重新浏览。"],
|
||||
busy: ["论文索引正在处理较多请求", "请稍候重试。你的搜索条件已保留。"],
|
||||
invalid_query: ["请调整搜索条件", "请缩短关键词或清除筛选后重试。"],
|
||||
unavailable: ["论文暂时无法读取", "请稍后重试。你的搜索条件已保留。"],
|
||||
}[data.reason] : null;
|
||||
function href(changes: Filters) { return "/?" + new URLSearchParams(Object.entries({ ...f, ...changes }).filter(([, v]) => typeof v === "string" && !!v) as [string, string][]).toString(); }
|
||||
if (!issue && data.page > Math.max(1, pages)) redirect(href({ page: String(Math.max(1, pages)) }));
|
||||
return <main id="main" className="page-shell"><section className="index-intro"><div className="section-kicker">THE OPEN RESEARCH READER <span>01 — INDEX</span></div><h1>好研究,<br /><span className="muted-heading">值得被读到。</span></h1><div className="intro-bottom"><p>从一个问题,到一篇论文。<br />发现研究、阅读摘要,回到每一项工作的原始出处。</p><a href="#papers" className="text-link">浏览论文 <ArrowRight size={16} /></a></div></section>
|
||||
{data.mode === "sample" && <div className="sample-note" role="note"><span className="status-dot" /><p>真实数据库样本 · 当前展示 2026-11-06 导出的前 20 条记录,搜索与筛选仅限此样本。</p><Link href="/about">数据说明 <ArrowUpRight size={13} /></Link></div>}
|
||||
<section id="papers" className="index-grid"><aside className="index-aside"><h2>研究领域</h2><div className="topic-list" role="region" aria-label="研究领域分类" tabIndex={0}><Link className={!f.topic ? "topic active" : "topic"} href={href({ topic: "", page: "1" })}>全部领域 <span>↗</span></Link>{data.topics.map(t => <Link key={t} className={f.topic === t ? "topic active" : "topic"} href={href({ topic: t, page: "1" })}>{t}</Link>)}</div><p className="aside-note">保留原始标题与摘要。<br />不把自动生成的解读当成论文结论。</p></aside><div className="paper-index"><div className="index-heading"><h2>{f.topic || "论文索引"}{data.total !== null && <span>{data.total.toLocaleString("zh-CN")}</span>}</h2><form className="sort-form"><input type="hidden" name="q" value={f.q || ""} /><input type="hidden" name="topic" value={f.topic || ""} /><input type="hidden" name="searchIn" value={f.searchIn || "text"} /><label htmlFor="sort" className="sr-only">排序</label><select id="sort" name="sort" defaultValue={f.sort || "recent"}><option value="recent">收录顺序</option><option value="title">标题 A–Z</option></select><button type="submit" className="quiet-button">排序</button></form></div>
|
||||
<form className="search-form" role="search"><Search size={18} aria-hidden /><select name="searchIn" aria-label="搜索范围" defaultValue={f.searchIn || "text"}><option value="text">标题 / 摘要</option><option value="author">作者</option></select><label htmlFor="paper-search" className="sr-only">搜索论文</label><input id="paper-search" name="q" placeholder={f.searchIn === "author" ? "搜索作者姓名…" : "搜索标题或摘要…"} defaultValue={f.q || ""} maxLength={200} /><input type="hidden" name="topic" value={f.topic || ""} /><input type="hidden" name="sort" value={f.sort || "recent"} /><button type="submit" aria-label="搜索论文"><ArrowRight size={18} /></button></form>
|
||||
{(f.q || f.topic) && <div className="filter-note"><span>{f.q ? `搜索 “${f.q}”` : f.topic}</span><Link href="/">清除筛选 <X size={13} /></Link></div>}
|
||||
{issue ? <div className="empty-state" role="alert"><h3>{issue[0]}</h3><p>{issue[1]}</p><div className="flex justify-center gap-4"><Button asChild variant="outline"><a href={href({})}>重新尝试</a></Button><Button asChild variant="ghost"><Link href="/">清除筛选</Link></Button></div></div> : data.items.length ? <ol className="paper-list">{data.items.map((p, i) => <li key={p.id}><article className="paper-row"><span className="row-number">{String((data.page - 1) * data.pageSize + i + 1).padStart(2, "0")}</span><div><div className="paper-meta"><span>{f.topic || p.topics[0] || "Research"}</span><span>{paperDate(p.publishedAt)}</span></div><h3><Link href={`/papers/${p.id}`}>{p.title}</Link></h3><p className="paper-summary">{p.abstract || "来源未提供摘要。"}</p><div className="row-bottom"><span className="authors">{p.authors.slice(0, 3).join(", ")}{p.authors.length > 3 ? ` +${p.authors.length - 3}` : ""}</span><Link href={`/papers/${p.id}`} className="read-link">阅读简介 <ArrowUpRight size={15} /></Link></div></div></article></li>)}</ol> : <div className="empty-state"><h3>没有找到符合条件的论文</h3><p>{data.mode === "sample" ? "当前只有 20 条样本,试试更短的关键词,或清除领域筛选。" : "试试其他关键词,或清除筛选。"}</p><Button asChild variant="outline"><Link href="/">查看全部论文</Link></Button></div>}
|
||||
{!issue && <nav className="pagination" aria-label="论文分页"><span>{data.total ? `${data.page} / ${Math.max(1,pages)}` : "0 条结果"}</span><div>{data.page > 1 && <Link href={href({ page: String(data.page - 1) })}>上一页</Link>}{data.page < pages && <Link href={href({ page: String(data.page + 1) })}>下一页 <ArrowRight size={14} /></Link>}</div></nav>}{data.total !== null && data.total > MAX_PAPER_PAGE * data.pageSize && <p className="source-caption" role="note">当前最多浏览前 {(MAX_PAPER_PAGE * data.pageSize).toLocaleString("zh-CN")} 条结果。搜索或筛选研究领域,可进一步缩小范围。</p>}</div></section></main>;
|
||||
return <>
|
||||
{issue ? <div className="empty-state" role="alert"><h3>{issue[0]}</h3><p>{issue[1]}</p><div className="flex justify-center gap-4"><Button asChild variant="outline"><a href={href(f, {})}>重新尝试</a></Button><Button asChild variant="ghost"><Link prefetch={false} href="/">清除筛选</Link></Button></div></div> : data.items.length ? <ol className="paper-list">{data.items.map((p, i) => <li key={p.id}><article className="paper-row"><span className="row-number">{String(i + 1).padStart(2, "0")}</span><div><div className="paper-meta"><span>{f.topic || p.topics[0] || "Research"}</span><span>{paperDate(p.publishedAt)}</span></div><h3><Link prefetch={false} href={`/papers/${p.id}`}>{p.title}</Link></h3><p className="paper-summary">{p.abstract || "来源未提供摘要。"}</p><div className="row-bottom"><span className="authors">{p.authors.slice(0, 3).join(", ")}{p.authors.length > 3 ? ` +${p.authors.length - 3}` : ""}</span><Link prefetch={false} href={`/papers/${p.id}`} className="read-link">阅读简介 <ArrowUpRight size={15} /></Link></div></div></article></li>)}</ol> : <div className="empty-state"><h3>没有找到符合条件的论文</h3><p>{data.mode === "sample" ? "当前只有 20 条样本,试试更短的关键词,或清除领域筛选。" : "试试其他关键词,或清除筛选。"}</p><Button asChild variant="outline"><Link prefetch={false} href="/">查看全部论文</Link></Button></div>}
|
||||
|
||||
{data.mode !== "error" && <nav className="pagination" aria-label="论文分页"><span>本页 {data.items.length} 篇</span><div>{data.previousCursor && <Link prefetch={false} href={href(f, { cursor: data.previousCursor })}>上一页</Link>}{data.nextCursor && <Link prefetch={false} href={href(f, { cursor: data.nextCursor })}>下一页 <ArrowRight size={14} /></Link>}</div></nav>}
|
||||
</>;
|
||||
}
|
||||
export default async function Home({ searchParams }: { searchParams: Promise<Record<string,string | string[] | undefined>> }) {
|
||||
const f = normalizeFilters(await searchParams);
|
||||
const result = listPapers(f);
|
||||
return <main id="main" className="page-shell"><section className="index-intro"><div className="section-kicker">THE OPEN RESEARCH READER <span>01 — INDEX</span></div><h1>好研究,<br /><span className="muted-heading">值得被读到。</span></h1><div className="intro-bottom"><p>从一个问题,到一篇论文。<br />发现研究、阅读摘要,回到每一项工作的原始出处。</p><a href="#papers" className="text-link">浏览论文 <ArrowRight size={16} /></a></div></section>
|
||||
|
||||
{!process.env.PAPER_API_ORIGIN && <div className="sample-note" role="note"><span className="status-dot" /><p>真实数据库样本 · 当前展示 2026-11-06 导出的前 20 条记录,搜索与筛选仅限此样本。</p><Link prefetch={false} href="/about">数据说明 <ArrowUpRight size={13} /></Link></div>}
|
||||
<section id="papers" className="index-grid"><Suspense key={JSON.stringify(f)+"topics"} fallback={<aside className="index-aside"><h2>研究领域</h2><p>正在读取领域…</p></aside>}><Topics result={result} f={f} /></Suspense><div className="paper-index" key={JSON.stringify(f)}><div className="index-heading"><h2>{f.topic || "论文索引"}</h2><Form action="/" scroll={false} className="sort-form"><input type="hidden" name="q" value={f.q || ""} /><input type="hidden" name="topic" value={f.topic || ""} /><input type="hidden" name="searchIn" value={f.searchIn || "text"} /><label htmlFor="sort" className="sr-only">排序</label><select id="sort" name="sort" defaultValue={f.sort || "recent"}><option value="recent">收录顺序</option><option value="title">标题 A–Z</option></select><SearchSubmit label="排序" /></Form></div>
|
||||
<Form action="/" scroll={false} className="search-form" role="search"><Search size={18} aria-hidden /><select name="searchIn" aria-label="搜索范围" defaultValue={f.searchIn || "text"}><option value="text">标题 / 摘要</option><option value="author">作者</option></select><label htmlFor="paper-search" className="sr-only">搜索论文</label><input id="paper-search" name="q" placeholder={f.searchIn === "author" ? "搜索作者姓名…" : "搜索标题或摘要…"} defaultValue={f.q || ""} maxLength={200} /><input type="hidden" name="topic" value={f.topic || ""} /><input type="hidden" name="sort" value={f.sort || "recent"} /><SearchSubmit label="搜索论文" icon /></Form>
|
||||
{(f.q || f.topic) && <div className="filter-note"><span>{f.q ? `搜索 “${f.q}”` : f.topic}</span><Link prefetch={false} href="/">清除筛选 <X size={13} /></Link></div>}
|
||||
|
||||
<Suspense key={JSON.stringify(f)} fallback={<Pending />}><Results result={result} f={f} /></Suspense>
|
||||
</div></section></main>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
"use client";
|
||||
import { useFormStatus } from "react-dom";
|
||||
import { ArrowRight, LoaderCircle } from "lucide-react";
|
||||
export function SearchSubmit({ label, icon = false }: { label: string; icon?: boolean }) {
|
||||
const { pending } = useFormStatus();
|
||||
return <button type="submit" aria-label={label} aria-busy={pending} className={icon ? undefined : "quiet-button"}>{pending ? <LoaderCircle size={18} className="animate-spin" aria-label="正在加载" /> : icon ? <ArrowRight size={18} /> : label}</button>;
|
||||
}
|
||||
+13
-15
@@ -1,16 +1,16 @@
|
||||
import { samplePage } from "./sample-page.mjs";
|
||||
import snapshot from "@/data/papers.json";
|
||||
import { createPaperTransport } from "./paper-api.mjs";
|
||||
|
||||
export type Paper = { id: string; title: string; abstract: string | null; authors: string[]; publishedAt: string | null; topics: string[]; sources: { label: string; url: string; externalId: string }[]; doi?: string | null; journal?: string | null; comments?: string | null; publications?: { venue: string; year: number; track: string; status: string; evidenceUrl?: string }[] };
|
||||
export type PaperPage = { items: Paper[]; total: number; page: number; pageSize: number; topics: string[]; mode: "sample" | "live"; capturedAt?: string };
|
||||
export type PaperFailure = { mode: "error"; reason: "timeout" | "busy" | "invalid_query" | "unavailable"; items: Paper[]; total: null; page: number; pageSize: number; topics: string[] };
|
||||
export type Filters = { q?: string; topic?: string; sort?: string; page?: string; searchIn?: string };
|
||||
export type PaperPage = { items: Paper[]; nextCursor: string; previousCursor: string; pageSize: number; topics: string[]; mode: "sample" | "live"; capturedAt?: string };
|
||||
export type PaperFailure = { mode: "error"; reason: "timeout" | "busy" | "invalid_query" | "unavailable"; items: Paper[]; pageSize: number; topics: string[] };
|
||||
export type Filters = { q?: string; topic?: string; sort?: string; cursor?: string; searchIn?: string };
|
||||
export function normalizeFilters(input: Record<string, string | string[] | undefined>): Filters {
|
||||
const value = (key: string) => typeof input[key] === "string" ? (input[key] as string).slice(0, 200) : undefined;
|
||||
return { q: value("q"), topic: value("topic"), sort: value("sort"), page: value("page"), searchIn: value("searchIn") };
|
||||
return { q: value("q"), topic: value("topic"), sort: value("sort"), cursor: typeof input.cursor === "string" ? input.cursor : undefined, searchIn: value("searchIn") };
|
||||
}
|
||||
const size = 8;
|
||||
export const MAX_PAPER_PAGE = 1000;
|
||||
export function safeExternalUrl(input: string | null | undefined) {
|
||||
if (!input) return null;
|
||||
try { const u = new URL(input); return u.protocol === "https:" || u.protocol === "http:" ? u.href : null; } catch { return null; }
|
||||
@@ -24,22 +24,20 @@ async function remote(path: string) {
|
||||
return createPaperTransport(process.env.PAPER_API_ORIGIN || "")(path);
|
||||
}
|
||||
export async function listPapers(filters: Filters): Promise<PaperPage | PaperFailure> {
|
||||
const page = Math.max(1, Math.min(MAX_PAPER_PAGE, Number.parseInt(filters.page || "1", 10) || 1));
|
||||
const q = (filters.q || "").trim().slice(0, 200), topic = filters.topic || "", sort = filters.sort === "title" ? "title" : "recent";
|
||||
if (process.env.PAPER_API_ORIGIN) {
|
||||
const params = new URLSearchParams({ q, topic, sort, searchIn: filters.searchIn === "author" ? "author" : "text", page: String(page), pageSize: String(size) });
|
||||
const failure = (status: number): PaperFailure => ({ mode: "error", reason: status === 504 ? "timeout" : status === 429 ? "busy" : status === 400 ? "invalid_query" : "unavailable", items: [], total: null, page, pageSize: size, topics: topic ? [topic] : [] });
|
||||
const params = new URLSearchParams({ q, topic, sort, searchIn: filters.searchIn === "author" ? "author" : "text", cursor: filters.cursor || "", pageSize: String(size) });
|
||||
const failure = (status: number): PaperFailure => ({ mode: "error", reason: status === 504 ? "timeout" : status === 429 ? "busy" : status === 400 ? "invalid_query" : "unavailable", items: [], pageSize: size, topics: topic ? [topic] : [] });
|
||||
if ((filters.cursor || "").length > 32768) return failure(400);
|
||||
const response = await remote("papers?" + params);
|
||||
if (!response.ok) return failure(response.status ?? 503);
|
||||
const data = response.data;
|
||||
if (!data || !Array.isArray(data.items) || !data.items.every(isPaper) || !Number.isSafeInteger(data.total) || data.total < 0 || !Array.isArray(data.topics) || !data.topics.every((x: unknown) => typeof x === "string")) return failure(502);
|
||||
return { ...data, page, pageSize: size, mode: "live" };
|
||||
if (!data || !Array.isArray(data.items) || !data.items.every(isPaper) || typeof data.nextCursor !== "string" || typeof data.previousCursor !== "string" || data.items.length > size || data.pageSize !== size || !Array.isArray(data.topics) || !data.topics.every((x: unknown) => typeof x === "string")) return failure(502);
|
||||
return { ...data, pageSize: size, mode: "live" };
|
||||
}
|
||||
const all: Paper[] = snapshot.papers;
|
||||
const topics = Array.from(new Set(all.flatMap(p => p.topics))).sort();
|
||||
const items = all.filter(p => (!topic || p.topics.includes(topic)) && (!q || (filters.searchIn === "author" ? p.authors : [p.title, p.abstract]).join(" ").toLocaleLowerCase().includes(q.toLocaleLowerCase())));
|
||||
items.sort((a, b) => sort === "title" ? a.title.localeCompare(b.title) : Number(b.id) - Number(a.id));
|
||||
return { items: items.slice((page - 1) * size, page * size), total: items.length, page, pageSize: size, topics, mode: "sample", capturedAt: snapshot.capturedAt };
|
||||
try {
|
||||
return { ...samplePage(snapshot.papers, { q, topic, sort, searchIn: filters.searchIn === "author" ? "author" : "text", cursor: filters.cursor || "", pageSize: size }), mode: "sample", capturedAt: snapshot.capturedAt };
|
||||
} catch { return { mode: "error", reason: "invalid_query", items: [], pageSize: size, topics: topic ? [topic] : [] }; }
|
||||
}
|
||||
export async function getPaper(id: string): Promise<Paper | null> {
|
||||
if (!/^[1-9][0-9]{0,18}$/.test(id) || BigInt(id) > 9223372036854775807n) return null;
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
// The explicit 20-record preview uses the same navigation shape as the API.
|
||||
// Its cursors are intentionally local to sample mode, never corpus positions.
|
||||
export function samplePage(papers, filters) {
|
||||
const { q = "", topic = "", sort = "recent", searchIn = "text", cursor = "", pageSize = 8 } = filters;
|
||||
const key = JSON.stringify([q, topic, sort, searchIn, pageSize]);
|
||||
const all = papers.filter(p => (!topic || p.topics.includes(topic)) && (!q || (searchIn === "author" ? p.authors : [p.title, p.abstract]).join(" ").toLocaleLowerCase().includes(q.toLocaleLowerCase())));
|
||||
all.sort((a,b) => sort === "title" ? a.title.localeCompare(b.title) || (BigInt(a.id) < BigInt(b.id) ? -1 : 1) : BigInt(a.id) > BigInt(b.id) ? -1 : 1);
|
||||
let start = 0;
|
||||
if (cursor) {
|
||||
if (cursor.length > 32768 || !/^[A-Za-z0-9_-]+$/.test(cursor)) throw Error("invalid_cursor");
|
||||
const position = JSON.parse(Buffer.from(cursor, "base64url").toString());
|
||||
const index = all.findIndex(p => p.id === position.id);
|
||||
if (position.sample !== 1 || position.key !== key || typeof position.before !== "boolean" || index < 0) throw Error("invalid_cursor");
|
||||
start = position.before ? Math.max(0, index-pageSize) : index+1;
|
||||
}
|
||||
const items = all.slice(start,start+pageSize);
|
||||
const encode = (p,before) => Buffer.from(JSON.stringify({sample:1,key,id:p.id,before})).toString("base64url");
|
||||
return {items, pageSize, topics:[...new Set(papers.flatMap(p=>p.topics))].sort(), previousCursor:start>0 && items.length ? encode(items[0],true) : "", nextCursor:start+items.length<all.length && items.length ? encode(items.at(-1),false) : ""};
|
||||
}
|
||||
@@ -21,14 +21,14 @@ test("sample index has working pagination and returns to the same list", async (
|
||||
const first = await page.locator(".paper-row h3").first().innerText();
|
||||
await page.screenshot({ path: info.outputPath("home.png"), fullPage: true });
|
||||
await page.getByRole("link", { name: "下一页" }).click();
|
||||
await expect(page).toHaveURL(/page=2/);
|
||||
await expect(page).toHaveURL(/cursor=/);
|
||||
await expect(page.locator(".paper-row h3").first()).not.toHaveText(first);
|
||||
await page.getByRole("link", { name: "上一页" }).click();
|
||||
await expect(page.locator(".paper-row h3").first()).toHaveText(first);
|
||||
await page.goto("/?page=999999");
|
||||
await expect(page).toHaveURL(/page=3/);
|
||||
await page.getByRole("link", { name: "下一页" }).click();
|
||||
await page.getByRole("link", { name: "下一页" }).click();
|
||||
await expect(page.locator(".paper-row")).toHaveCount(4);
|
||||
await expect(page.getByRole("navigation", { name: "论文分页" })).toContainText("3 / 3");
|
||||
await expect(page.getByRole("navigation", { name: "论文分页" })).toContainText("本页 4 篇");
|
||||
await expect(page.getByRole("link", { name: "下一页" })).toHaveCount(0);
|
||||
});
|
||||
|
||||
@@ -38,10 +38,13 @@ test("search, empty results, author scope and category filters work", async ({ p
|
||||
await page.getByRole("button", { name: "搜索论文", exact: true }).click();
|
||||
await expect(page.getByRole("heading", { name: "没有找到符合条件的论文" })).toBeVisible();
|
||||
await page.getByRole("link", { name: "查看全部论文" }).click();
|
||||
await expect(page.getByRole("textbox", {name:"搜索论文",exact:true})).toHaveValue("");
|
||||
await expect(page.locator(".paper-row")).toHaveCount(8);
|
||||
const author = snapshot.papers[0].authors[0];
|
||||
await page.getByRole("combobox", { name: "搜索范围", exact: true }).selectOption("author");
|
||||
await page.getByRole("textbox", { name: "搜索论文", exact: true }).fill(author);
|
||||
await page.getByRole("button", { name: "搜索论文", exact: true }).click();
|
||||
await expect(page).toHaveURL(/searchIn=author/);
|
||||
await expect(page.locator(".paper-row").first()).toContainText(author);
|
||||
await page.getByRole("link", { name: "清除筛选" }).click();
|
||||
const topic = snapshot.papers[0].topics[0];
|
||||
|
||||
Vendored
+6
-4
@@ -14,10 +14,10 @@ const snapshot = JSON.parse(await readFile(join(root, "src/data/papers.json"), "
|
||||
const sessions = new Set(), grants = new Map();
|
||||
let meFailure = false, logoutFailure = false, verified = false;
|
||||
let counts = {};
|
||||
let paperStatus = 0, paperMalformed = false;
|
||||
let paperStatus = 0, paperMalformed = false, paperDelay = 0;
|
||||
function reset() {
|
||||
sessions.clear(); grants.clear(); meFailure = false; logoutFailure = false; verified = false;
|
||||
paperStatus = 0; paperMalformed = false;
|
||||
paperStatus = 0; paperMalformed = false; paperDelay = 0;
|
||||
counts = { exchange: 0, verified: 0, logout: 0, google: 0, slack: 0 };
|
||||
}
|
||||
reset();
|
||||
@@ -47,6 +47,7 @@ const server = createServer(async (req, res) => {
|
||||
if (typeof data.logoutFailure === "boolean") logoutFailure = data.logoutFailure;
|
||||
if ([0, 400, 429, 503, 504].includes(data.paperStatus)) paperStatus = data.paperStatus;
|
||||
if (typeof data.paperMalformed === "boolean") paperMalformed = data.paperMalformed;
|
||||
if (typeof data.paperDelay === "number") paperDelay = Math.min(5000, Math.max(0,data.paperDelay));
|
||||
return reply(res, 200, {});
|
||||
}
|
||||
if (url.pathname.startsWith("/projects/thesisly/research/papers/")) {
|
||||
@@ -54,11 +55,12 @@ const server = createServer(async (req, res) => {
|
||||
return reply(res, paper ? 200 : 404, paper || { error: "not_found" });
|
||||
}
|
||||
if (url.pathname === "/projects/thesisly/research/papers") {
|
||||
if (paperDelay) await new Promise(resolve => setTimeout(resolve, paperDelay));
|
||||
if (paperStatus) return reply(res, paperStatus, { error: "synthetic research failure" });
|
||||
if (paperMalformed) return reply(res, 200, { unexpected: true });
|
||||
const page = Number(url.searchParams.get("page") || 1), pageSize = Number(url.searchParams.get("pageSize") || 8);
|
||||
const pageSize = Number(url.searchParams.get("pageSize") || 8);
|
||||
// Fixture transport data, not an implementation of Core search semantics.
|
||||
return reply(res, 200, { items: snapshot.papers.slice((page - 1) * pageSize, page * pageSize), total: snapshot.papers.length, page, pageSize, topics: [...new Set(snapshot.papers.flatMap(p => p.topics))] });
|
||||
return reply(res, 200, { items: snapshot.papers.slice(0, pageSize), nextCursor: "", previousCursor: "", pageSize, topics: [...new Set(snapshot.papers.flatMap(p => p.topics))] });
|
||||
}
|
||||
const match = /^\/projects\/thesisly\/auth\/([a-z-]+)$/.exec(url.pathname);
|
||||
if (!match) return reply(res, 404, { error: "not_found" });
|
||||
|
||||
@@ -5,14 +5,14 @@ const origin = "https://core.example.test/projects/thesisly/research/";
|
||||
|
||||
test("paper transport uses the anonymous no-store API without forwarding site credentials", async () => {
|
||||
const transport = createPaperTransport(origin, async (url, options) => {
|
||||
assert.equal(url.href, origin + "papers?q=graph&page=2");
|
||||
assert.equal(url.href, origin + "papers?q=graph&cursor=opaque");
|
||||
assert.equal(options.cache, "no-store");
|
||||
assert.equal(options.redirect, "error");
|
||||
assert.equal(options.headers, undefined);
|
||||
assert.ok(options.signal instanceof AbortSignal);
|
||||
return Response.json({ items: [], total: 0, topics: [], page: 2, pageSize: 8 });
|
||||
return Response.json({ items: [], nextCursor: "", previousCursor: "", topics: [], pageSize: 8 });
|
||||
});
|
||||
assert.deepEqual(await transport("papers?q=graph&page=2"), { ok: true, data: { items: [], total: 0, topics: [], page: 2, pageSize: 8 } });
|
||||
assert.deepEqual(await transport("papers?q=graph&cursor=opaque"), { ok: true, data: { items: [], nextCursor: "", previousCursor: "", topics: [], pageSize: 8 } });
|
||||
});
|
||||
|
||||
test("HTTP errors stay distinguishable and never become empty search results", async () => {
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { samplePage } from '../src/lib/sample-page.mjs';
|
||||
const papers=JSON.parse(readFileSync(new URL('../src/data/papers.json',import.meta.url))).papers;
|
||||
test('sample cursor traversal reaches every record and reverses without duplication',()=>{
|
||||
for(const sort of ['recent','title']) {
|
||||
let cursor='',previous=[];const seen=[];
|
||||
do {
|
||||
const page=samplePage(papers,{sort,cursor,pageSize:8});
|
||||
if(previous.length) assert.deepEqual(samplePage(papers,{sort,cursor:page.previousCursor,pageSize:8}).items.map(p=>p.id),previous);
|
||||
previous=page.items.map(p=>p.id);seen.push(...previous);cursor=page.nextCursor;
|
||||
}while(cursor);
|
||||
assert.equal(seen.length,20);assert.equal(new Set(seen).size,20);
|
||||
}
|
||||
});
|
||||
test('sample cursors reject cross-filter reuse and malformed input',()=>{
|
||||
const page=samplePage(papers,{});
|
||||
for(const filters of [{q:'graph'},{topic:'cs.LG'},{sort:'title'},{searchIn:'author'},{pageSize:9}]) assert.throws(()=>samplePage(papers,{...filters,cursor:page.nextCursor}));
|
||||
for(const cursor of ['!','invalid', 'a'.repeat(32769)]) assert.throws(()=>samplePage(papers,{cursor}));
|
||||
});
|
||||
test('sample title ties and IDs beyond Number precision have deterministic order',()=>{
|
||||
const rows=['9007199254740993','9007199254740992','1'].map(id=>({...papers[0],id,title:'Same title'}));
|
||||
assert.deepEqual(samplePage(rows,{sort:'title'}).items.map(p=>p.id),['1','9007199254740992','9007199254740993']);
|
||||
assert.deepEqual(samplePage(rows,{}).items.map(p=>p.id),['9007199254740993','9007199254740992','1']);
|
||||
});
|
||||
@@ -42,3 +42,21 @@ test("malformed research responses do not become empty results or sample mode",
|
||||
await expect(page.getByRole("textbox", { name: "搜索论文", exact: true })).toHaveValue("keep-this-query");
|
||||
await expect(page.locator(".paper-row, .sample-note, .index-heading h2 span, .pagination")).toHaveCount(0);
|
||||
});
|
||||
|
||||
|
||||
test("slow results stream after usable search controls and searches retain the document", async ({ page, request }) => {
|
||||
await control(request, { paperDelay: 3000 });
|
||||
await page.goto("/", { waitUntil: "commit" });
|
||||
await expect(page.getByRole("textbox", {name:"搜索论文",exact:true})).toBeVisible({timeout:2000});
|
||||
await expect(page.getByRole("status")).toContainText("正在读取论文索引");
|
||||
await expect(page.locator(".paper-row")).toHaveCount(8);
|
||||
await page.evaluate(() => { window.__readerDocument = "preserved"; });
|
||||
let documents = 0, sessions = 0;
|
||||
page.on("request", req => { if(req.isNavigationRequest() && req.frame() === page.mainFrame()) documents++; if(new URL(req.url()).pathname.endsWith("/auth/me")) sessions++; });
|
||||
await page.getByRole("textbox", {name:"搜索论文",exact:true}).fill("graph");
|
||||
await page.getByRole("button", {name:"搜索论文",exact:true}).click();
|
||||
await expect(page).toHaveURL(/q=graph/);
|
||||
await expect(page.locator(".paper-row")).toHaveCount(8);
|
||||
expect(await page.evaluate(() => window.__readerDocument)).toBe("preserved");
|
||||
expect(documents).toBe(0); expect(sessions).toBe(0);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user