From 1c18cbe03a2e571eaf8261c5ba6e5c10cb4ed0ab Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Fri, 6 Nov 2026 17:32:24 +0800 Subject: [PATCH] Package Paper Atlas with organized modules and a unified CLI --- .gitea/workflows/check.yml | 20 +- .gitignore | 4 + README.md | 344 +++--------------- docs/data-model.md | 21 ++ docs/data-sources.md | 223 ++++++++++++ docs/development.md | 32 ++ docs/operations.md | 21 ++ docs/usage.md | 45 +++ paper_atlas/__init__.py | 3 + paper_atlas/__main__.py | 3 + paper_atlas/audits/__init__.py | 1 + .../audits/coverage.py | 15 +- .../audits/iclr.py | 14 +- paper_atlas/cli.py | 45 +++ paper_atlas/collectors/__init__.py | 1 + .../collectors/acl.py | 12 +- .../collectors/dblp.py | 14 +- .../collectors/iclr.py | 12 +- .../collectors/openreview.py | 12 +- .../collectors/peerread.py | 12 +- .../collectors/pmlr.py | 54 +-- .../collectors/proceedings.py | 63 +--- .../collectors/vision.py | 12 +- paper_atlas/config.py | 4 + paper_atlas/db/__init__.py | 1 + schema.sql => paper_atlas/db/schema.sql | 0 index_core.py => paper_atlas/db/store.py | 7 +- .../db/submissions.py | 0 paper_atlas/ingestion/__init__.py | 1 + .../ingestion/arxiv.py | 12 +- .../ingestion/backfill.py | 14 +- .../ingestion/maintenance.py | 19 +- .../ingestion/records.py | 14 +- paper_atlas/io/__init__.py | 1 + .../io/git_metadata.py | 0 paper_atlas/io/html.py | 52 +++ paper_atlas/io/http.py | 52 +++ .../io/snapshots.py | 10 +- query.py => paper_atlas/querying.py | 6 +- paper_atlas/resources/__init__.py | 1 + .../resources/venues.json | 0 pyproject.toml | 22 ++ requirements.txt | 1 - tests/__init__.py | 1 + test_acl.py => tests/test_acl.py | 2 +- tests/test_cli.py | 32 ++ .../test_coverage_report.py | 4 +- .../test_dblp_catalogue.py | 2 +- .../test_download_snapshot.py | 16 +- test_fetch.py => tests/test_fetch.py | 12 +- test_iclr.py => tests/test_iclr.py | 8 +- .../test_iclr_snapshot_audit.py | 2 +- .../test_import_arxiv.py | 2 +- .../test_index_core.py | 4 +- .../test_maintenance.py | 10 +- .../test_openreview_snapshot.py | 4 +- test_peerread.py => tests/test_peerread.py | 8 +- test_pmlr.py => tests/test_pmlr.py | 8 +- .../test_proceedings.py | 9 +- test_query.py => tests/test_query.py | 10 +- .../test_submission_evidence.py | 13 +- test_vision.py => tests/test_vision.py | 6 +- 62 files changed, 835 insertions(+), 518 deletions(-) create mode 100644 docs/data-model.md create mode 100644 docs/data-sources.md create mode 100644 docs/development.md create mode 100644 docs/operations.md create mode 100644 docs/usage.md create mode 100644 paper_atlas/__init__.py create mode 100644 paper_atlas/__main__.py create mode 100644 paper_atlas/audits/__init__.py rename coverage_report.py => paper_atlas/audits/coverage.py (84%) rename audit_iclr_snapshot.py => paper_atlas/audits/iclr.py (94%) create mode 100644 paper_atlas/cli.py create mode 100644 paper_atlas/collectors/__init__.py rename collect_acl.py => paper_atlas/collectors/acl.py (97%) rename catalogue_dblp.py => paper_atlas/collectors/dblp.py (95%) rename collect_iclr.py => paper_atlas/collectors/iclr.py (98%) rename collect_openreview_snapshot.py => paper_atlas/collectors/openreview.py (98%) rename collect_peerread.py => paper_atlas/collectors/peerread.py (95%) rename collect_pmlr.py => paper_atlas/collectors/pmlr.py (79%) rename collect_proceedings.py => paper_atlas/collectors/proceedings.py (83%) rename collect_vision.py => paper_atlas/collectors/vision.py (96%) create mode 100644 paper_atlas/config.py create mode 100644 paper_atlas/db/__init__.py rename schema.sql => paper_atlas/db/schema.sql (100%) rename index_core.py => paper_atlas/db/store.py (97%) rename submission_evidence.py => paper_atlas/db/submissions.py (100%) create mode 100644 paper_atlas/ingestion/__init__.py rename import_arxiv.py => paper_atlas/ingestion/arxiv.py (96%) rename backfill_submission_evidence.py => paper_atlas/ingestion/backfill.py (90%) rename merge_conferences.py => paper_atlas/ingestion/maintenance.py (84%) rename import_records.py => paper_atlas/ingestion/records.py (95%) create mode 100644 paper_atlas/io/__init__.py rename metadata_checkout.py => paper_atlas/io/git_metadata.py (100%) create mode 100644 paper_atlas/io/html.py create mode 100644 paper_atlas/io/http.py rename download_snapshot.py => paper_atlas/io/snapshots.py (96%) rename query.py => paper_atlas/querying.py (98%) create mode 100644 paper_atlas/resources/__init__.py rename venues.json => paper_atlas/resources/venues.json (100%) create mode 100644 pyproject.toml delete mode 100644 requirements.txt create mode 100644 tests/__init__.py rename test_acl.py => tests/test_acl.py (96%) create mode 100644 tests/test_cli.py rename test_coverage_report.py => tests/test_coverage_report.py (96%) rename test_dblp_catalogue.py => tests/test_dblp_catalogue.py (98%) rename test_download_snapshot.py => tests/test_download_snapshot.py (79%) rename test_fetch.py => tests/test_fetch.py (66%) rename test_iclr.py => tests/test_iclr.py (95%) rename test_iclr_snapshot_audit.py => tests/test_iclr_snapshot_audit.py (98%) rename test_import_arxiv.py => tests/test_import_arxiv.py (98%) rename test_index_core.py => tests/test_index_core.py (98%) rename test_maintenance.py => tests/test_maintenance.py (72%) rename test_openreview_snapshot.py => tests/test_openreview_snapshot.py (97%) rename test_peerread.py => tests/test_peerread.py (73%) rename test_pmlr.py => tests/test_pmlr.py (78%) rename test_proceedings.py => tests/test_proceedings.py (90%) rename test_query.py => tests/test_query.py (82%) rename test_submission_evidence.py => tests/test_submission_evidence.py (96%) rename test_vision.py => tests/test_vision.py (93%) diff --git a/.gitea/workflows/check.yml b/.gitea/workflows/check.yml index 5fbeb95..261a81d 100644 --- a/.gitea/workflows/check.yml +++ b/.gitea/workflows/check.yml @@ -26,10 +26,24 @@ jobs: root = pathlib.Path(tmp) with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar: tar.extractall(root, filter='data') - candidates = list(root.glob('*/test_import_arxiv.py')) + candidates = list(root.glob('*/pyproject.toml')) if len(candidates)!=1: raise SystemExit('Unexpected repository archive layout') src = candidates[0].parent - subprocess.run(['python3','-B','-m','unittest','discover','-v'],cwd=src,check=True) - subprocess.run(['python3','-B','query.py','--help'],cwd=src,check=True) + subprocess.run(['python3','-B','-m','unittest','discover','-s','tests','-t','.','-v'],cwd=src,check=True) + # Build and installation checks run only on the approved remote runner. + venv = root / 'venv' + subprocess.run(['python3','-B','-m','venv','--system-site-packages',str(venv)],check=True) + python = str(venv / 'bin/python') + wheels = root / 'wheels' + subprocess.run([python,'-B','-m','pip','wheel','--no-deps','--wheel-dir',str(wheels),str(src)],check=True) + wheel, = wheels.glob('paper_atlas-*.whl') + subprocess.run([python,'-B','-m','pip','install','--no-deps',str(wheel)],check=True) + outside = root / 'outside-checkout' + outside.mkdir() + subprocess.run([str(venv/'bin/paper-atlas'),'--help'],cwd=outside,check=True) + subprocess.run([python,'-I','-B','-m','paper_atlas','collect','cvf','--help'],cwd=outside,check=True) + smoke = "from paper_atlas.db.store import Store; from paper_atlas.config import DEFAULT_REGISTRY; import json; assert len(json.loads(DEFAULT_REGISTRY.read_text())['venues'])==41; s=Store('installed.sqlite'); s.conn.close()" + subprocess.run([python,'-I','-B','-c',smoke],cwd=outside,check=True) + subprocess.run([python,'-I','-B','-m','paper_atlas','query','--db','installed.sqlite','--stats'],cwd=outside,check=True) PY diff --git a/.gitignore b/.gitignore index b0a58ff..f9964d8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,8 @@ __pycache__/ +.venv/ +build/ +dist/ +*.egg-info/ *.pyc *.sqlite *.sqlite-* diff --git a/README.md b/README.md index 02d83d0..1cf25a1 100644 --- a/README.md +++ b/README.md @@ -1,313 +1,85 @@ -# Paper Atlas · 论文元数据与投稿历史索引 +# Paper Atlas -Paper Atlas 是一个**长期维护、按需或按批次运行的研究数据工具项目**。它不是一次性的下载脚本,也不需要为了采集而持续启动 Web 服务。目标是把 arXiv、重要 AI 会议论文集及公开投稿决定整合成可追溯、可查询、可持续更新的数据库,供后续研究趋势、跨会投稿、主题演化等分析使用。 +**长期维护、按需运行的论文元数据与公开投稿历史工具。** -## 项目目标与当前进度 +Paper Atlas 将 arXiv、AI 会议论文集及公开投稿决定整理到可追溯的 SQLite 数据库中,用于查询和后续研究分析。它按批次运行,不需要常驻 Web 服务;采集标题、摘要、标识、标签及决定证据,不下载论文全文。 -目标包括:完整历史元数据导入、逐年会议目录、跨平台论文去重、公开拒稿/撤稿信息、平台标签与自定义标签隔离、可恢复更新和覆盖率报告。 +项目仍在建设中。候选范围包含 41 个会议,尚未完成全部历史数据和最终去重复核。实际进度以数据库、来源清单和覆盖报告为准,而不是仓库文件数量。 -**目前仍在开发,不能视为完整数据集。** 已实现 SQLite 数据结构、arXiv 元数据 ZIP 流式导入、断点恢复、跨平台标识合并及审计、PMLR、COLM、NeurIPS、ACL Anthology、ICLR 官方公开程序、CVF/ECVA 视觉会议元数据采集、带清单核验的会议导入和只读查询。PeerRead 提供公开 ICLR 2017 明确决定的历史补充;近期 ICLR 公开决定快照已解析,实际主库进度以运行日志与统计为准。arXiv 元数据 ZIP 已完整下载(1,878,831,586 字节),主批次读取 3,233,620 行至 EOF;主库有 3,233,527 条 arXiv 来源记录,93 行差额仍需按原始 ID 独立核验。这些不是去重后的全库论文数,最终全库审计尚未完成。其他会议适配器、历史缺失届次、完整投稿历史、跨平台疑似重复复核和覆盖率核验仍待完成。回归检查在远端执行,已配置 ces-control:host 对应的 CI 工作流,实际 CI 结果以 Gitea Actions 为准。`venues.json` 的 41 个会议是候选覆盖范围,不代表已完成采集,也不是与 COLM 比较的客观排名。 +## 项目结构 -## 数据范围与来源 - -- arXiv:官方推荐的 Cornell/Kaggle 元数据快照,包含标识、作者、标题、摘要、类别、版本与外部索引。默认导入整个快照,通过类别筛选 AI 论文,避免永久丢弃交叉领域记录。 -- 会议论文集:优先官方来源,计划接入 OpenReview、PMLR、ACL Anthology、NeurIPS、CVF、ECVA、AAAI、IJCAI;DBLP 用于补充会议目录、标识与出版信息。GitHub 聚合名单只作发现线索,不能作为未经核验的决定证据。 -- 投稿历史:只收录公开可访问并有证据的记录;没有录用信息不等于拒稿,不能据此推断“从未中会”。 -- 当前不下载 PDF、LaTeX、图片或补充材料,不提取论文全文,不推断匿名作者身份。 - -官方入口: - -- [arXiv bulk metadata](https://info.arxiv.org/help/bulk_data/index.html) -- [arXiv metadata snapshot](https://www.kaggle.com/datasets/Cornell-University/arxiv) -- [OpenReview 数据接口说明](https://docs.openreview.net/how-to-guides/data-retrieval-and-modification/how-to-get-all-notes-for-submissions-reviews-rebuttals-etc) -- [PMLR](https://proceedings.mlr.press/) -- [ACL Anthology 权威 XML 与 API](https://aclanthology.org/info/development/) -- [NeurIPS](https://proceedings.neurips.cc/) -- [COLM](https://proceedings.colm.cc/) -- [CVF](https://openaccess.thecvf.com/) -- [ECVA](https://www.ecva.net/papers.php) -- [IJCAI](https://www.ijcai.org/all_proceedings/) -- [AAAI](https://ojs.aaai.org/index.php/AAAI/issue/archive) -- [DBLP](https://dblp.org/db/conf/) - -## 候选会议覆盖目录 - -| 领域 | 会议系列 | 主要发现入口 | -| --- | --- | --- | -| 机器学习、理论 | NeurIPS、ICML、ICLR、COLM、COLT、AISTATS、UAI、CLeaR | OpenReview、PMLR、NeurIPS、COLM 官方论文集 | -| 自然语言 | ACL、EMNLP、NAACL、EACL、COLING、CoNLL | ACL Anthology | -| 视觉 | CVPR、ICCV、ECCV | CVF、ECVA | -| 综合 AI | AAAI、IJCAI、ECAI | 官方论文集、DBLP | -| 检索与数据 | KDD、SIGIR、WWW、WSDM、RecSys、CIKM、ICDM、SDM、ECML-PKDD | 官方出版方、DBLP,具体年份入口待逐项登记 | -| 机器人 | ICRA、IROS、RSS、CoRL | 官方论文集、PMLR、DBLP | -| 规划、知识与智能体 | ICAPS、KR、AAMAS | 官方论文集、DBLP | -| 语音与音频 | ICASSP、INTERSPEECH、ISMIR | 官方论文集、ISCA、DBLP | -| 相关专门领域 | MICCAI、FAccT | 官方出版方、DBLP | - -这些是领域覆盖候选,不构成统一排名,也不能据此声称已全部入库。逐年目录以真实举办届次和正式出版清单为准:双年会议不补造不存在的年份;主会、Findings、workshop 分开。`edition_sources` 保存实际采集状态,不能用候选名单代替覆盖率报告。 - -## 数据模型 - -| 表 | 用途 | -| --- | --- | -| `papers` / `papers_fts` | 稳定论文实体、代表性标题摘要、FTS5 全文检索 | -| `identifiers` | arXiv ID、DOI 等唯一外部标识 | -| `source_records` / `sources` | 平台记录、原平台标题摘要与索引元数据、来源 URL 和抓取时间 | -| `venues` / `venue_aliases` / `editions` | 会议或期刊系列、年份与 track,区分主会、Findings 和 workshop,联合会议用别名查询,不重复生成事件 | -| `edition_sources` | 每届每个来源的覆盖状态、预计/已导入数量、错误与断点 | -| `submissions` / `submission_identifiers` / `submission_sources` | 投稿事件、按届次限定的原平台强标识、多来源证据;同稿的不同投稿尝试分别保留 | -| `decisions` / `decision_sources` | 明确决定、原始决定日期、来源观察日期及证据,不把缺失决定编造成拒稿 | -| `submission_merges` / `decision_merges` | 投稿事件与重复决定观察的合并审计,保留来源关联并避免重用已合并的编号 | -| `tags` / `paper_tags` | 平台标签与人工标签,使用命名空间隔离 | -| `match_candidates` / `paper_merges` | 潜在重复复核状态、确定合并审计 | -| `ingest_runs` | 导入批次、状态、错误和恢复位置 | - -决定状态为 `accepted`、`rejected`、`desk_rejected`、`withdrawn`、`under_review`、`unknown`。一篇论文可以对应多次投稿和多个决定,不能用单一“最终会议”字段覆盖历史。查询中按观察时间选择最近状态;后续分析需要同时检查来源覆盖率。 - -当前确定性去重使用 arXiv ID、单个 DOI 和 OpenReview forum ID;跨平台同标题还需至少两位作者吻合,或作者吻合且摘要相同,才自动合并。保留各平台源记录,避免合并后丢失出处。规范化标题只建检索索引,尚未实现模糊匹配流程;**现阶段不保证跨平台去重已经完成**。不同平台标签不自动等同,自定义标签应使用 `custom:*` 命名空间。 - -## 运行与查询 - -基础导入和查询依赖 Python 3 标准库和带 FTS5 的 SQLite;PMLR 采集另需 `requirements.txt` 中锁定的 PyYAML 6.0.3。无项目构建步骤。服务器已检查为 Python 3.14.4。所有运行数据放在仓库外,不提交数据库、下载包或访问凭据。 - -服务器暂存目录:`/data/paper-index/`,快照目录:`/data/paper-index/raw/`,运行代码目录:`/data/paper-index/code/`。这些路径不是对外访问的 API。本地工作区只用于编辑代码、文档和 Git;采集、全量导入和运行检查均在用户确认的服务器执行。 - -下载完成并验证 ZIP 后,在服务器运行: - -```sh -python3 -B import_arxiv.py \ - --db /data/paper-index/papers.sqlite \ - --archive /data/paper-index/raw/arxiv-metadata.zip +```text +paper_atlas/ + cli.py 统一命令入口 + collectors/ 各来源采集与目录盘点 + ingestion/ 导入、身份回填与单写入协调 + db/ 数据模型、身份解析与 SQL schema + audits/ 来源核验和覆盖报告 + io/ 网络、快照下载、HTML 与 Git 元数据读取 + resources/ 候选会议目录 + querying.py 只读查询 +tests/ 回归与集成验证 +docs/ 使用、数据源、数据模型和维护说明 +pyproject.toml 包元数据、依赖和命令注册 +.gitea/workflows/ 远程 CI 与安装验证 ``` -导入直接读取压缩包,不落盘展开整个 JSON。每 500 条提交一次事务并保存断点。若同一快照的导入异常中断,用相同命令恢复;恢复阶段需要重新顺序读取并跳过已提交行。不得同时启动多个写入进程。待恢复批次的文件路径、大小和修改时间必须一致;下载快照校验信息应保存在 `raw/arxiv-manifest.json`。 +## 安装与入口 -全量导入是有实质磁盘和 I/O 成本的操作。先检查 `df -h`,保持足够空间;数据库、FTS 索引和 WAL 都会增长,压缩包大小不是最终数据库占用。索引和数据库生成属于数据处理,不会构建应用镜像。 +以下安装、测试及批次运行在已确认的远程服务器 / `ces-control:host` runner 上执行。已在 Python 3.14 环境验证;项目要求 Python 3.12 及以上。 ```sh -# 导入状态、记录量与覆盖状态 -python3 -B query.py --db /data/paper-index/papers.sqlite --stats - -# 查询标题或摘要,支持 SQLite FTS5 查询语法 -python3 -B query.py --db /data/paper-index/papers.sqlite --query '"language model"' --limit 20 - -# 按 arXiv 平台类别筛选 -python3 -B query.py --db /data/paper-index/papers.sqlite --tag arxiv:category=cs.AI - -# 会议与决定查询(会议数据导入后才有结果) -python3 -B query.py --db /data/paper-index/papers.sqlite --venue COLM --year 2025 --status rejected +python3 -m venv .venv +.venv/bin/python -m pip install . +source .venv/bin/activate +paper-atlas --help ``` -查询连接使用 SQLite 只读模式,结果输出 JSON。`--stats` 中 `papers` 是按当前规则去重后的论文实体数,`records_by_source` 是各平台已入库记录数,`decision_events_by_status` 是决定事件数。采集文件 manifest 的 `count` 是已采集数量,不能等同已入库数量,也不能把各平台记录数相加当成独立论文数。 +从源码目录也可以使用同一命令树:`python3 -B -m paper_atlas --help`。无需执行散落的脚本文件。 -全部目标会议的覆盖报告会保留尚无入库记录的项目,避免只展示已经做完的会议: +## 常用流程 ```sh -python3 -B coverage_report.py --db /data/paper-index/papers.sqlite \ +# 查看当前入库量;不同平台记录数不等于独立论文数 +paper-atlas query --db /data/paper-index/papers.sqlite --stats + +# 查询标题与摘要 +paper-atlas query --db /data/paper-index/papers.sqlite --query '"language model"' + +# 查看一篇论文的来源、标签和所有已知投稿历史 +paper-atlas query --db /data/paper-index/papers.sqlite --paper 123 + +# 列出全部目标会议,包括尚未入库的项目 +paper-atlas coverage --db /data/paper-index/papers.sqlite \ --out /data/paper-index/collected/venue-coverage-current.json -``` -报告按会议、年份和 track 列出投稿数、独立论文数及来源清单状态,包含联合会议别名。没有数据的目标明确标为 no_imported_records;已有数据也保持“历史待独立核验”,不会因单个文件导入通过就宣称历年覆盖完成。同一论文可以出现于多个会议或年份,各会议论文数也不能直接相加作为全库数量。 - -用 `--paper 531782` 可查看单篇论文的所有已知来源、平台标签、投稿届次及带证据的决定历史。会议筛选不区分大小写,状态筛选使用该次投稿的最新已知决定。当前没有公共 Web 页面或网络查询服务。 - -## 官方会议元数据采集 - -```sh -# 单卷示例:ICML 2025,读取官方 gh-pages 元数据,不下载论文正文 -python3 -B collect_pmlr.py --cache /data/paper-index/raw/http-cache \ - --out /data/paper-index/collected --volume 267 - -# 待主库完成 arXiv 导入、且没有其他写入进程后,合并会议记录 -python3 -B import_records.py --db /data/paper-index/papers.sqlite \ - /data/paper-index/collected/pmlr-v267.jsonl -``` - -采集输出 JSONL 和对应 manifest,只有明确来源标签才产生相应决定:官方论文集记录为 accepted;PeerRead 使用其明确的 accepted 布尔标签,缺失标签保留 unknown。清单数目不一致时整个文件导入回滚,记录失败批次,不能标为覆盖完成。多个历史卷的全量覆盖仍需单独核验。旧卷可能把 PDF 放入归档;采集器对前 50 卷使用 Git partial clone 和 sparse checkout,仅取 `_posts` 和卷配置,不执行出版方项目代码。较新归档若超出 64 MiB 限额,也转为此方式。已对原本超限的 AISTATS 2009(第 5 卷)验证采集 84 条且工作区无 PDF。旧 YAML 的 `!binary` 文本、空文档尾标记会安全解析;带年份的 AISTATS/COLT 等历史 shortname 会规范化,避免名称别名漏届;缺失 year 时仅从官方会议 start 日期提取年份。`pmlr-volume-status.json` 逐卷保存结果和候选范围归属,`pmlr-last-run.json` 记录本批实际请求范围,局部重试不能证明全历史完成。缓存默认有效 24 小时;同一次批次可复用缓存,后续更新会重新读取动态入口。 - -PMLR 的同一出版 ID 可能对应多份 Git 元数据,例如 CoRL 2023 的两篇论文分别存在不同日期的副本。只有论文内容与标识完全一致、差异限于元数据日期时才合为一条,并保留源文件路径和日期版本;冲突副本会使该卷采集失败,保留上一份有效文件。manifest 分别记录 metadata_post_count、duplicate_post_count 和论文 count。 - -会议导入器还会独立检查每条记录的会议、年份、track 与 manifest 是否一致,并从实际记录计算缺失摘要数;旧 manifest 即使声称摘要完整,也不能让缺失摘要的来源被标为 complete。缺失的源摘要与合并后论文实体是否从其他来源补得摘要分别统计。 - -PeerRead 采集仅选择作者公开发布的 ICLR 2017 投稿元数据,固定到源仓库 commit。只依据明确的布尔 `accepted` 标签形成 accepted/rejected;空标签为 unknown,不使用其他数据分区中的 probably-rejected 推断。不会保存 reviews 或 parsed_pdfs 正文。 - -```sh -python3 -B collect_peerread.py --cache /data/paper-index/raw/http-cache \ +# 采集与导入分开;这里只采集到独立文件 +paper-atlas collect colm --cache /data/paper-index/raw/http-cache \ --out /data/paper-index/collected ``` -数据任务以一次性 systemd 服务运行:`paper-atlas-arxiv-import`、`paper-atlas-pmlr-collect`、`paper-atlas-peerread-collect`;`paper-atlas-pmlr-verify` 用于全索引复核,`paper-atlas-conference-merge` 用于单写入合并。它们不是定时任务。主库只允许一个写入进程;会议采集先写独立 JSONL,待 arXiv 主批次结束后再合并;也可以使用 `merge_conferences.py` 在保存断点处暂停本项目的 arXiv unit、顺序合并会议清单并恢复。该操作应作为独立的一次性 systemd 任务运行,避免 SSH 断开影响恢复;不能同时启动多个合并任务。运行日志位于 `/data/paper-index/*-import.log`、`/data/paper-index/*-collect.log`。任何服务启动成功都不等于批次已完成,需检查批次状态、清单与完整性结果。标签按来源刷新使用 `paper_tags_source_evidence` 索引,避免每条论文都扫描整张标签表。运行中的 arXiv 可以通过同一协调脚本的 `--schema-only` 模式在断点更新索引后恢复;此模式要求已有 arXiv unit 正在运行。 +`paper-atlas collect --help` 查看来源;`paper-atlas import --help` 查看导入方式。完整批次步骤见[使用指南](docs/usage.md)。 -### 投稿事件身份与旧数据回填 +## 数据如何组织 -论文实体与投稿事件分开去重。同一篇论文可以在同一年向同一会议投稿多次,不能仅凭 paper_id、会议、标题相同就把投稿历史压成一次。跨来源只有原平台 forum ID、已验证的投稿编号,或正式发表稿的同届同 track 出版 DOI 等强证据才能关联同一事件。程序展示的内部编号仅在经过独立核验并带有 program_submission_number_verified 标记时才能用作投稿编号。 +- **论文和投稿分开。** 一篇论文可以多次投稿;某次被拒、后来在其他会议录用,两段历史都保留。 +- **决定必须有证据。** 评分、未出现在录用名单、没有公开决定,均不能推断为 Reject。未发现录用也不等于从未录用。 +- **来源和标签保留出处。** 不同平台标签分开,自定义标签使用 `custom:*`;确认合并时保留源记录与审计。 +- **覆盖范围逐届核对。** 主会、Findings、workshop 等分别记录。单个文件检查通过不能证明整个会议历年完整。 +- **数据库只允许单写入。** 采集任务先写独立文件,核验后由协调器合并;原始数据和数据库均放在仓库外。 -新版 ICLR manifest 的 parser_version 为 2,明确区别投稿编号、forum 和展示事件编号。旧版文件应先用缓存重新采集;事件证据回填只接受已入库、与 manifest 的会议/年份/track 和数量一致的文件。队列 JSON 包含 files 及对应的 file_sha256,文件变化后必须重新核验,不能继续使用旧哈希。 +## 文档 -```sh -python3 -B merge_conferences.py --db /data/paper-index/papers.sqlite \ - --archive /data/paper-index/raw/arxiv-metadata.zip \ - --backfill-queue /data/paper-index/collected/submission-evidence-queue.json -``` +| 内容 | 入口 | +| --- | --- | +| 安装后怎么采集、导入和查询 | [使用指南](docs/usage.md) | +| 数据范围、各采集器和来源限制 | [数据源说明](docs/data-sources.md) | +| 论文、投稿、决定、标签及去重规则 | [数据模型](docs/data-model.md) | +| 批次恢复、更新和最终核验 | [运行维护](docs/operations.md) | +| 模块职责、开发和 CI | [开发说明](docs/development.md) | -协调器只维护本项目的 arXiv unit,并对维护过程加互斥锁。若 arXiv 正在运行,会保存断点、应用 schema、回填来源身份、导入本次附加文件、检查外键后恢复;若批次已经读至 EOF 并标记 complete,则不再启动它。若导入停下但未完成,会报错要求先调查,不以“unit 不存在”推断成功或重新全量运行。应以一次性 systemd 任务运行该命令,保持单写入。 +## 验证与交付 -首次升级会给已有投稿与决定补上来源关联;身份回填不重新导入标题摘要,不更改原抓取时间。确认同一次投稿后,可以合并镜像事件并保留所有决定及来源。当前稿件的原始强标识也会写入 source_records 元数据,后续源 URL 更新会同步保存。 +CI 在已授权的远程 runner 上运行测试、构建 wheel,并从源码目录外验证安装后的命令及随包资源。数据库全量去重和覆盖率是独立的数据验收步骤,不因代码测试通过而自动完成。 -通用 JSONL 导入支持 decision_events 数组及 observed_at、decision_date、submitted_at。没有取得明确决定时,可用 unknown 状态和空数组仅保存投稿,不生成虚假的决定;`--status unknown` 也能查询这些投稿。历史快照使用其实际观察时间,不能因今天导入就覆盖较新来源的状态;同一观察时间内按原始决定日期排序。反复导入相同证据只补来源关联,不重复制造决定事件。全量结束后仍须单独核验重复候选、冲突决定及来源覆盖,迁移检查不能代替最终全库审计。 - -### 固定版本的补充元数据快照 - -`download_snapshot.py` 流式下载到 `.part`,核对完整 HTTP 响应、预期大小和可信来源发布的校验和后,才发布正式文件及 manifest。已存在的正式文件会先重新核验;错误校验和不会覆盖它。默认保留 8 GiB 空闲空间,同一输出只允许一个下载进程。 - -DBLP 官方月度快照用于补充出版目录和历史届次,元数据按 CC0 发布。固定版本可复现,不把每日变动下载链接当作同一快照: - -```sh -python3 -B download_snapshot.py \ - --url https://drops.dagstuhl.de/storage/artifacts/dblp/xml/2026/dblp-2026-11-01.xml.gz \ - --md5 18ad5db59592406ec74289975c4b44cb \ - --output /data/paper-index/raw/dblp-2026-11-01.xml.gz -``` - -[官方版本与校验值](https://doi.org/10.4230/dblp.xml.2026-11-01)。下载器另外计算 SHA-256,记录来源 URL、大小和核验时间。DBLP 没有提供的摘要不能编造,出版目录也不能作为拒稿证据。 - -DBLP 目录盘点先核验固定快照与[官方 DTD](https://doi.org/10.4230/dblp.xml.dtd.2023-06-28),再流式读取 XML,直至 gzip EOF 检查通过: - -```sh -python3 -B download_snapshot.py \ - --url https://drops.dagstuhl.de/storage/artifacts/dblp/xml/2023/dblp-2023-06-28.dtd \ - --md5 96e4283e71886dec2173718957293648 \ - --output /data/paper-index/raw/dblp-2023-06-28.dtd -python3 -B catalogue_dblp.py \ - --archive /data/paper-index/raw/dblp-2026-11-01.xml.gz \ - --dtd /data/paper-index/raw/dblp-2023-06-28.dtd \ - --out /data/paper-index/collected/dblp -``` - -`dblp-catalogue.sqlite` 保存论文集目录、按 crossref/年份/booktitle 的记录数量、各目录族数量及少量样本;`dblp-ai-candidates.jsonl` 保存候选会议族的原始索引元数据。存在多个出版卷字段的记录另存 `volume_anomalies`,保留全部值供归属核验;统计中仍只计一条,不任取第一个字段或展开成多篇。候选范围包含历史名称与 workshop,尚未赋予主会归属或录用状态。后续须依据具体论文集和届次核对目标范围、track 与出版标识,才能生成入库文件;不会把同一目录下的所有记录都视为主会。该盘点不下载 PDF,也不更改论文主库。 - -XML 只允许校验过的本地 DTD;官方通用名 `dblp.dtd` 与月度快照使用的 `dblp-2023-06-28.dtd` 均解析到同一份已校验文件。拒绝其他外部实体和内部 DTD 子集,不访问 XML 声明的任意网络地址,并遵循源 XML 的字符编码。临时目录库或 gzip 检查失败时不发布正式候选文件,需先调查残留的 `.part` 状态,再决定是否重新运行。 - -公开 ICLR 投稿快照的固定 Release 供后续筛选投稿和明确决定;它是第三方保存的 OpenReview 历史副本,不等于最新官方全量数据: - -```sh -python3 -B download_snapshot.py \ - --url https://github.com/qhjqhj00/iclr-openreview-reviews/releases/download/v1.0/iclr.tar.bz2 \ - --sha256 350071daf25ebf1aef854ee1d5dc3a81834e1c468917c82a4f78af5548de34fc \ - --expected-bytes 512940722 \ - --output /data/paper-index/raw/iclr-openreview-v1.0.tar.bz2 -``` - -[快照来源与复现脚本](https://github.com/qhjqhj00/iclr-openreview-reviews)。原包包含评审回复;解析只筛选公开投稿的索引、标题、摘要、平台标签及带证据的明确决定,不提取评审正文、作者邮件或论文全文。必须核对原始 invitation、readers、签名及决定字段,不能根据评分或录用名单缺席推断拒稿。 - -优先处理近期三届,再单独补全 2020–2023;不以 2017 年的 PeerRead 样本代表近期拒稿覆盖: - -```sh -python3 -B collect_openreview_snapshot.py \ - --archive /data/paper-index/raw/iclr-openreview-v1.0.tar.bz2 \ - --out /data/paper-index/collected --year 2024 --year 2025 --year 2026 -``` - -解析器先核验固定 SHA-256,再流式读取 tar,以临时 SQLite 按年份和 forum 关联投稿及决定,兼容回复先于投稿的归档顺序。只接受明确对 everyone 公开的投稿和具有会议主席签名的 Decision;保留原始决定值、note ID、决定日期、invitation 和签名证据。无明确决定的投稿保留 unknown,不产生拒稿推断。无法关联到公开投稿元数据的公开决定另存 unmatched-decisions 文件,供复核,不擅自创建论文。输出清单分别统计投稿状态、决定事件、缺失摘要和排除原因;重复副本去重,内容冲突会中止。导入时若同一编号指向不同 forum,或同一 forum 指向不同投稿编号,会报冲突并回滚,不强制合并。 - -该版本是 2026-05-08 发布的第三方历史副本,不代表平台当前状态或全部投稿;缺失决定及公开性筛选均需核验。采集成功也不等于主库已更新,后续应通过单写入协调器导入,并核对同届官方程序与公开决定的差异。 - -入库前按真实 forum 对照同届官方程序,生成逐年状态、名单缺口、历史决定差异和文本版本差异报告,以及带 SHA-256 的现有程序身份回填队列: - -```sh -python3 -B audit_iclr_snapshot.py --root /data/paper-index/collected \ - --year 2024 --year 2025 --year 2026 \ - --report /data/paper-index/collected/iclr-recent-audit.json -``` - -该检查要求清单和记录的届次、数量、摘要缺失统计一致,拒绝重复 forum、编号冲突及缺失公开决定证据。报告中的名单差异和 unknown 不会转换成拒稿。不同时间来源的决定保留各自证据,强 forum 标识负责关联同一次投稿;报告通过不等于平台全量覆盖或最终全库去重已经完成。生成的 `*-backfill-queue.json` 仅用于已入库程序记录的身份回填,快照文件仍须由协调器单独导入。 - -下载异常后先检查对应进程或 CI 任务确已终止,再使用同一参数增加 `--resume`。只有断点身份一致且服务器实际返回正确 Range 响应才续传;不支持时保留断点并报错,不自动重下。正式文件与下载清单均放在仓库外,不提交大型来源包。 - -### COLM 与 NeurIPS 官方摘要 - -```sh -python3 -B collect_proceedings.py --source colm \ - --cache /data/paper-index/raw/http-cache --out /data/paper-index/collected -python3 -B collect_proceedings.py --source neurips \ - --cache /data/paper-index/raw/http-cache --out /data/paper-index/collected -``` - -公共元数据请求对连接重置、超时及不完整响应最多尝试三次,保留原有有效缓存;HTTP 403 等访问拒绝会直接记录失败。声明 Content-Length 与实际响应不符时不发布缓存,已有缓存也受字节上限约束。旧页面没有赋值的 HTML 属性按空值处理,避免某一个空 class 属性阻断整届采集。 - -采集器从官方根目录发现实际发表届次,仅访问索引和 Abstract HTML 页面,不请求 PDF、补充材料或源码。可用重复 `--year` 限定实际存在的届次;不存在的年份会报错,局部重试保留其他届次的已有状态。每个来源串行请求,实际网络响应后等待 1 秒;缓存命中不额外等待。 - -COLM 当前目录提供 2024–2026 三届 pre-proceedings,按会议年份保存,保留平台实际出版日期和出版状态,不把晚补刊日期当作会议年份。NeurIPS 当前目录提供 1987–2025,并包含 2025 独立卷及 2021 独立 Datasets and Benchmarks 索引。主会、datasets_benchmarks、position、creative_ai 分开出文件,不混为同一个 track。跨平台自标识仅从出版方 paper-actions 区域链接与 citation 元数据提取,不把摘要里的引用链接当作当前论文 ID。manifest 的 `parser_version` 至少为 2 才允许入库;旧文件须利用缓存重新采集,避免错误身份合并。首次真实样本核对:COLM 2024 官方目录 299 个摘要入口;NeurIPS 1987 为 90 个,作者与摘要均已验证解析。样本成功不代表所有页面采集完成。 - -`colm-index-status.json`、`neurips-index-status.json` 保存逐索引状态;对应 `*-last-run.json` 保存本次实际选择范围。每个 JSONL 的 manifest 记录索引入口数、track 记录数和缺失摘要数。论文数量与官方页面声明不一致会失败,缺失摘要会标记 partial;不会把页脚、姓名政策或其他正文当作摘要。 - -这两个采集器只写采集文件,合并仍通过单写入协调流程。全量历史覆盖和所有未实现会议仍待最终核验。 - -### ACL Anthology 权威 XML - -```sh -python3 -B collect_acl.py \ - --checkout /data/paper-index/raw/metadata-checkouts/acl-anthology \ - --out /data/paper-index/collected -``` - -`metadata_checkout.py` 对官方仓库做只包含 `data/xml` 的 partial clone/sparse checkout,不获取 PDF,不执行仓库 hooks 或构建。每批固定到实际 Git commit。遍历 XML 的 volume venue 字段选择 ACL、EMNLP、NAACL、EACL、COLING、CoNLL;Findings 按所属会议独立成 track,不按文件名把其他同场 workshop 冒充主会。 - -新旧 Anthology ID 按官方规则构造,并核对源 DOI。保留 C69、D19-50 至 D19-66、W 系列的历史编号例外,排除编号 0 的前言。XML 混合标记通过 itertext 提取;无摘要保留为空并标记 partial。主会、Findings、workshop、demo、tutorial、industry、shared_task 分开。 - -联合举办会议保存一个会议事件及多个名称别名。例如 LREC-COLING 的论文可用 COLING 或 LREC 查询,仍只有一次该届投稿记录。可用 `--track main` 限定主会,避免把 Findings 或演示算入主会;别名数据在正常单写入合并时应用 schema。 - -`acl-collection-status.json` 记录每个 XML 及其 volume 的处理结果和排除原因;`acl-last-run.json` 记录源 commit 与本批范围。`--collection P17` 可限定一个确实存在的 collection。已验证真实 ACL 2017 长文 195 条、短文 107 条,二者摘要完整;其他 track 按源实际缺失情况记录,不能凭样本宣称整个历史完成。 - -### ICLR 官方公开程序元数据 - -```sh -python3 -B collect_iclr.py --cache /data/paper-index/raw/http-cache \ - --out /data/paper-index/collected -# 入库后按官方主题检索;主题值以实际来源为准 -python3 -B query.py --db /data/paper-index/papers.sqlite \ - --venue ICLR --tag 'iclr_program:topic=Reinforcement Learning->Deep RL' -``` - -从 ICLR 官方年份导航登记届次,读取对应 Papers 页面明确引用的 orals-posters/abstracts JSON。核对声明数量、分页状态及唯一 event ID;只下载公开程序元数据,不访问用户资料、PDF、海报、幻灯片或视频。`--year` 可限制年份;清单中的已采集数与数据库已入库数分别统计。 - -官方程序中的 event 数不是论文数:同一篇文章可以有 poster 和 oral 两场展示。程序还会给负 sourceid 的 oral 展示生成占位 OpenReview 链接,这些链接不能作为真实论文标识。采集器核对原投稿编号、标题与作者后合并展示,优先使用正编号记录的真实论文链接;原展示编号和类型保留在源元数据中。 - -同一投稿编号的展示标题可能有小幅修订。只有负编号 oral 与正编号原稿对应、至少两位作者完全一致且标题相似度满足严格阈值时,才保留为同稿的标题变体;该规则仅在一届官方程序内部生效,不用于跨平台模糊合并。 - -只将 Conference 来源的论文视为 ICLR 主会录用,排除 Blog 和期刊转会展示,并在审计清单保存排除计数。2020/2021 旧数据允许依据官方主会 paper program 形成录用证据;无 forum ID 时保留 program 身份和匹配候选,不伪造标识。历史年度返回 404 会登记 unavailable,未来未发表届次登记 not_yet_published,其他访问或解析失败登记 blocked;这些状态都不能宣称该年已覆盖。 - -主题、关键词使用 `iclr_program:topic`、`iclr_program:keyword` 命名空间。通用导入器按源记录刷新标签证据,保留同一论文其他源记录及 `custom:*` 标签。每年的 JSONL、manifest、`iclr-program-year-status.json` 和 `iclr-program-last-run.json` 保存实际范围、独立展示数、主会展示数、论文数、缺失摘要与缺失 forum 数。录用程序不能提供拒稿全集,公开投稿及拒稿补充仍需独立来源。 - -### CVF / ECVA 视觉会议元数据 - -```sh -python3 -B collect_vision.py --source cvf \ - --cache /data/paper-index/raw/http-cache --out /data/paper-index/collected -python3 -B collect_vision.py --source ecva \ - --cache /data/paper-index/raw/http-cache --out /data/paper-index/collected -``` - -CVF 从官方根目录发现 CVPR、ICCV 主会与 Findings,分别保存 track;不把 workshop 或 WACV 算入这些会议。优先跟随页面明确提供的 All Papers 链接,避免只收录默认显示的第一天。ECVA 从官方总目录按论文 URL 的会议与年份分组,当前总目录列有 ECCV 2018、2020、2022、2024。不存在于来源的 `--venue`、`--year` 或二者组合会报错,不补造双年会议届次,也不能因此推断旧届次未举办。 - -只访问论文索引与摘要 HTML/PHP 页面。作者优先取 citation 元数据,旧页面尊重声明编码;无 citation 作者时取索引中的作者行,排除论文集出版信息。DOI 只取该论文条目所属的 DOI 链接或 citation 字段;冲突会失败,不从摘要引用中抽取当前论文 ID。不请求 PDF、补充材料、视频或代码。 - -每份清单记录索引入口数、论文数、缺失摘要与作者数,缺失内容保留为空并标记 partial。存在未被标题条目解析的论文链接时会失败,防止静默漏项。逐来源的 discovery、index-status、last-run 文件分别保存发现目录、逐届结果和实际请求范围。可重复使用 `--year` 或 `--venue` 限定重试;其他届次状态会保留。官方开放目录之外的早期届次、尚未刊出的新届次和独立出版方历史数据仍需后续补齐,采集器完成不代表全历史覆盖已完成。 - -## 长期维护与更新 - -1. 每次采集先核对来源、许可、可访问性、空闲空间和现有运行批次。保持低并发,遵守来源限流、重试与人机验证要求。 -2. 下载先写 `.part`,确认完整 HTTP 响应和 ZIP 目录,再记录 SHA-256 与时间后切换为正式文件。正式导入会读至 EOF,从而验证成员 CRC。 -3. 每次导入保留来源证据和批次,按会议/年份/track 报告覆盖状态。缺失摘要留空,不编造文本;缺失决定保留未知。 -4. 核验行数、唯一标识、外键、全文查询和 SQLite 完整性后,再将批次视为交付。样本通过不能证明全量正确。 -5. 数据库备份使用 SQLite backup API 或一致性快照,不能在写入过程中只复制 `.sqlite` 主文件而遗漏 WAL。按明确保留策略轮换备份,避免无上限积累。 -6. 不为每次数据更新生成新 Docker 镜像;不常驻下载器;不自动创建定时任务。本项目的更新任务应按需或经用户授权调度。 -7. 代码通过工作分支和 PR 交付;构建或 CI 只能使用用户确认的远程 runner,不在本机触发构建。 - -## 当前访问限制 - -2026-11-06 从目标服务器测试:Kaggle 元数据 GET 可下载;OpenReview 会议 group 接口可读,但 notes 接口返回 `ChallengeRequiredError`。拒稿记录导入尚受此限制,不能用录用名单替代全部投稿,也不能声称已覆盖全部拒稿。需通过平台支持的访问方式或有合法来源的公开数据导出继续补全。 - -已核验的采集文件计数:ICLR 2020–2026 主会 15,534 条、COLM 2024–2026 共 1,573 条,摘要均已取得;候选范围内的 PMLR 文件为 31,428 条,已剔除两份重复出版元数据。ACL Anthology 已选文件为 63,297 条,其中 15,147 条源记录缺摘要。这些是采集文件的来源记录数,不能相加当作去重论文数,也不能代替主库实时统计;实际入库量使用 query.py --stats 查看。 - -代码在 `codex/metadata-index` 工作分支交付,[Draft PR #1](https://gitea.entroia.com/Zayden-Jung/paper-atlas/pulls/1) 尚未合并,等待全量就绪后用户手动合并。各提交分别由远程 CI 验证,结果以对应提交的 Gitea Actions 检查为准。全量导入、其他会议适配器、去重复核和覆盖率核验继续进行,应随实际结果更新此说明。 +开发通过工作分支和 PR 交付,由用户手动合并。 diff --git a/docs/data-model.md b/docs/data-model.md new file mode 100644 index 0000000..c67a91b --- /dev/null +++ b/docs/data-model.md @@ -0,0 +1,21 @@ +# 数据模型与身份解析 + + +| 表 | 用途 | +| --- | --- | +| `papers` / `papers_fts` | 稳定论文实体、代表性标题摘要、FTS5 全文检索 | +| `identifiers` | arXiv ID、DOI 等唯一外部标识 | +| `source_records` / `sources` | 平台记录、原平台标题摘要与索引元数据、来源 URL 和抓取时间 | +| `venues` / `venue_aliases` / `editions` | 会议或期刊系列、年份与 track,区分主会、Findings 和 workshop,联合会议用别名查询,不重复生成事件 | +| `edition_sources` | 每届每个来源的覆盖状态、预计/已导入数量、错误与断点 | +| `submissions` / `submission_identifiers` / `submission_sources` | 投稿事件、按届次限定的原平台强标识、多来源证据;同稿的不同投稿尝试分别保留 | +| `decisions` / `decision_sources` | 明确决定、原始决定日期、来源观察日期及证据,不把缺失决定编造成拒稿 | +| `submission_merges` / `decision_merges` | 投稿事件与重复决定观察的合并审计,保留来源关联并避免重用已合并的编号 | +| `tags` / `paper_tags` | 平台标签与人工标签,使用命名空间隔离 | +| `match_candidates` / `paper_merges` | 潜在重复复核状态、确定合并审计 | +| `ingest_runs` | 导入批次、状态、错误和恢复位置 | + +决定状态为 `accepted`、`rejected`、`desk_rejected`、`withdrawn`、`under_review`、`unknown`。一篇论文可以对应多次投稿和多个决定,不能用单一“最终会议”字段覆盖历史。查询中按观察时间选择最近状态;后续分析需要同时检查来源覆盖率。 + +当前确定性去重使用 arXiv ID、单个 DOI 和 OpenReview forum ID;跨平台同标题还需至少两位作者吻合,或作者吻合且摘要相同,才自动合并。保留各平台源记录,避免合并后丢失出处。规范化标题只建检索索引,尚未实现模糊匹配流程;**现阶段不保证跨平台去重已经完成**。不同平台标签不自动等同,自定义标签应使用 `custom:*` 命名空间。 + diff --git a/docs/data-sources.md b/docs/data-sources.md new file mode 100644 index 0000000..fb351e5 --- /dev/null +++ b/docs/data-sources.md @@ -0,0 +1,223 @@ +# 数据范围与采集器 + +## 数据范围与来源 + +- arXiv:官方推荐的 Cornell/Kaggle 元数据快照,包含标识、作者、标题、摘要、类别、版本与外部索引。默认导入整个快照,通过类别筛选 AI 论文,避免永久丢弃交叉领域记录。 +- 会议论文集:优先官方来源,计划接入 OpenReview、PMLR、ACL Anthology、NeurIPS、CVF、ECVA、AAAI、IJCAI;DBLP 用于补充会议目录、标识与出版信息。GitHub 聚合名单只作发现线索,不能作为未经核验的决定证据。 +- 投稿历史:只收录公开可访问并有证据的记录;没有录用信息不等于拒稿,不能据此推断“从未中会”。 +- 当前不下载 PDF、LaTeX、图片或补充材料,不提取论文全文,不推断匿名作者身份。 + +官方入口: + +- [arXiv bulk metadata](https://info.arxiv.org/help/bulk_data/index.html) +- [arXiv metadata snapshot](https://www.kaggle.com/datasets/Cornell-University/arxiv) +- [OpenReview 数据接口说明](https://docs.openreview.net/how-to-guides/data-retrieval-and-modification/how-to-get-all-notes-for-submissions-reviews-rebuttals-etc) +- [PMLR](https://proceedings.mlr.press/) +- [ACL Anthology 权威 XML 与 API](https://aclanthology.org/info/development/) +- [NeurIPS](https://proceedings.neurips.cc/) +- [COLM](https://proceedings.colm.cc/) +- [CVF](https://openaccess.thecvf.com/) +- [ECVA](https://www.ecva.net/papers.php) +- [IJCAI](https://www.ijcai.org/all_proceedings/) +- [AAAI](https://ojs.aaai.org/index.php/AAAI/issue/archive) +- [DBLP](https://dblp.org/db/conf/) + +## 候选会议覆盖目录 + +| 领域 | 会议系列 | 主要发现入口 | +| --- | --- | --- | +| 机器学习、理论 | NeurIPS、ICML、ICLR、COLM、COLT、AISTATS、UAI、CLeaR | OpenReview、PMLR、NeurIPS、COLM 官方论文集 | +| 自然语言 | ACL、EMNLP、NAACL、EACL、COLING、CoNLL | ACL Anthology | +| 视觉 | CVPR、ICCV、ECCV | CVF、ECVA | +| 综合 AI | AAAI、IJCAI、ECAI | 官方论文集、DBLP | +| 检索与数据 | KDD、SIGIR、WWW、WSDM、RecSys、CIKM、ICDM、SDM、ECML-PKDD | 官方出版方、DBLP,具体年份入口待逐项登记 | +| 机器人 | ICRA、IROS、RSS、CoRL | 官方论文集、PMLR、DBLP | +| 规划、知识与智能体 | ICAPS、KR、AAMAS | 官方论文集、DBLP | +| 语音与音频 | ICASSP、INTERSPEECH、ISMIR | 官方论文集、ISCA、DBLP | +| 相关专门领域 | MICCAI、FAccT | 官方出版方、DBLP | + +这些是领域覆盖候选,不构成统一排名,也不能据此声称已全部入库。逐年目录以真实举办届次和正式出版清单为准:双年会议不补造不存在的年份;主会、Findings、workshop 分开。`edition_sources` 保存实际采集状态,不能用候选名单代替覆盖率报告。 + +## 官方会议元数据采集 + +```sh +# 单卷示例:ICML 2025,读取官方 gh-pages 元数据,不下载论文正文 +python3 -B -m paper_atlas collect pmlr --cache /data/paper-index/raw/http-cache \ + --out /data/paper-index/collected --volume 267 + +# 待主库完成 arXiv 导入、且没有其他写入进程后,合并会议记录 +python3 -B -m paper_atlas import records --db /data/paper-index/papers.sqlite \ + /data/paper-index/collected/pmlr-v267.jsonl +``` + +采集输出 JSONL 和对应 manifest,只有明确来源标签才产生相应决定:官方论文集记录为 accepted;PeerRead 使用其明确的 accepted 布尔标签,缺失标签保留 unknown。清单数目不一致时整个文件导入回滚,记录失败批次,不能标为覆盖完成。多个历史卷的全量覆盖仍需单独核验。旧卷可能把 PDF 放入归档;采集器对前 50 卷使用 Git partial clone 和 sparse checkout,仅取 `_posts` 和卷配置,不执行出版方项目代码。较新归档若超出 64 MiB 限额,也转为此方式。已对原本超限的 AISTATS 2009(第 5 卷)验证采集 84 条且工作区无 PDF。旧 YAML 的 `!binary` 文本、空文档尾标记会安全解析;带年份的 AISTATS/COLT 等历史 shortname 会规范化,避免名称别名漏届;缺失 year 时仅从官方会议 start 日期提取年份。`pmlr-volume-status.json` 逐卷保存结果和候选范围归属,`pmlr-last-run.json` 记录本批实际请求范围,局部重试不能证明全历史完成。缓存默认有效 24 小时;同一次批次可复用缓存,后续更新会重新读取动态入口。 + +PMLR 的同一出版 ID 可能对应多份 Git 元数据,例如 CoRL 2023 的两篇论文分别存在不同日期的副本。只有论文内容与标识完全一致、差异限于元数据日期时才合为一条,并保留源文件路径和日期版本;冲突副本会使该卷采集失败,保留上一份有效文件。manifest 分别记录 metadata_post_count、duplicate_post_count 和论文 count。 + +会议导入器还会独立检查每条记录的会议、年份、track 与 manifest 是否一致,并从实际记录计算缺失摘要数;旧 manifest 即使声称摘要完整,也不能让缺失摘要的来源被标为 complete。缺失的源摘要与合并后论文实体是否从其他来源补得摘要分别统计。 + +PeerRead 采集仅选择作者公开发布的 ICLR 2017 投稿元数据,固定到源仓库 commit。只依据明确的布尔 `accepted` 标签形成 accepted/rejected;空标签为 unknown,不使用其他数据分区中的 probably-rejected 推断。不会保存 reviews 或 parsed_pdfs 正文。 + +```sh +python3 -B -m paper_atlas collect peerread --cache /data/paper-index/raw/http-cache \ + --out /data/paper-index/collected +``` + +数据任务以一次性 systemd 服务运行:`paper-atlas-arxiv-import`、`paper-atlas-pmlr-collect`、`paper-atlas-peerread-collect`;`paper-atlas-pmlr-verify` 用于全索引复核,`paper-atlas-conference-merge` 用于单写入合并。它们不是定时任务。主库只允许一个写入进程;会议采集先写独立 JSONL,待 arXiv 主批次结束后再合并;也可以使用 `paper-atlas maintain merge` 在保存断点处暂停本项目的 arXiv unit、顺序合并会议清单并恢复。该操作应作为独立的一次性 systemd 任务运行,避免 SSH 断开影响恢复;不能同时启动多个合并任务。运行日志位于 `/data/paper-index/*-import.log`、`/data/paper-index/*-collect.log`。任何服务启动成功都不等于批次已完成,需检查批次状态、清单与完整性结果。标签按来源刷新使用 `paper_tags_source_evidence` 索引,避免每条论文都扫描整张标签表。运行中的 arXiv 可以通过同一协调脚本的 `--schema-only` 模式在断点更新索引后恢复;此模式要求已有 arXiv unit 正在运行。 + +### 投稿事件身份与旧数据回填 + +论文实体与投稿事件分开去重。同一篇论文可以在同一年向同一会议投稿多次,不能仅凭 paper_id、会议、标题相同就把投稿历史压成一次。跨来源只有原平台 forum ID、已验证的投稿编号,或正式发表稿的同届同 track 出版 DOI 等强证据才能关联同一事件。程序展示的内部编号仅在经过独立核验并带有 program_submission_number_verified 标记时才能用作投稿编号。 + +新版 ICLR manifest 的 parser_version 为 2,明确区别投稿编号、forum 和展示事件编号。旧版文件应先用缓存重新采集;事件证据回填只接受已入库、与 manifest 的会议/年份/track 和数量一致的文件。队列 JSON 包含 files 及对应的 file_sha256,文件变化后必须重新核验,不能继续使用旧哈希。 + +```sh +python3 -B -m paper_atlas maintain merge --db /data/paper-index/papers.sqlite \ + --archive /data/paper-index/raw/arxiv-metadata.zip \ + --backfill-queue /data/paper-index/collected/submission-evidence-queue.json +``` + +协调器只维护本项目的 arXiv unit,并对维护过程加互斥锁。若 arXiv 正在运行,会保存断点、应用 schema、回填来源身份、导入本次附加文件、检查外键后恢复;若批次已经读至 EOF 并标记 complete,则不再启动它。若导入停下但未完成,会报错要求先调查,不以“unit 不存在”推断成功或重新全量运行。应以一次性 systemd 任务运行该命令,保持单写入。 + +首次升级会给已有投稿与决定补上来源关联;身份回填不重新导入标题摘要,不更改原抓取时间。确认同一次投稿后,可以合并镜像事件并保留所有决定及来源。当前稿件的原始强标识也会写入 source_records 元数据,后续源 URL 更新会同步保存。 + +通用 JSONL 导入支持 decision_events 数组及 observed_at、decision_date、submitted_at。没有取得明确决定时,可用 unknown 状态和空数组仅保存投稿,不生成虚假的决定;`--status unknown` 也能查询这些投稿。历史快照使用其实际观察时间,不能因今天导入就覆盖较新来源的状态;同一观察时间内按原始决定日期排序。反复导入相同证据只补来源关联,不重复制造决定事件。全量结束后仍须单独核验重复候选、冲突决定及来源覆盖,迁移检查不能代替最终全库审计。 + +### 固定版本的补充元数据快照 + +`paper-atlas download` 流式下载到 `.part`,核对完整 HTTP 响应、预期大小和可信来源发布的校验和后,才发布正式文件及 manifest。已存在的正式文件会先重新核验;错误校验和不会覆盖它。默认保留 8 GiB 空闲空间,同一输出只允许一个下载进程。 + +DBLP 官方月度快照用于补充出版目录和历史届次,元数据按 CC0 发布。固定版本可复现,不把每日变动下载链接当作同一快照: + +```sh +python3 -B -m paper_atlas download \ + --url https://drops.dagstuhl.de/storage/artifacts/dblp/xml/2026/dblp-2026-11-01.xml.gz \ + --md5 18ad5db59592406ec74289975c4b44cb \ + --output /data/paper-index/raw/dblp-2026-11-01.xml.gz +``` + +[官方版本与校验值](https://doi.org/10.4230/dblp.xml.2026-11-01)。下载器另外计算 SHA-256,记录来源 URL、大小和核验时间。DBLP 没有提供的摘要不能编造,出版目录也不能作为拒稿证据。 + +DBLP 目录盘点先核验固定快照与[官方 DTD](https://doi.org/10.4230/dblp.xml.dtd.2023-06-28),再流式读取 XML,直至 gzip EOF 检查通过: + +```sh +python3 -B -m paper_atlas download \ + --url https://drops.dagstuhl.de/storage/artifacts/dblp/xml/2023/dblp-2023-06-28.dtd \ + --md5 96e4283e71886dec2173718957293648 \ + --output /data/paper-index/raw/dblp-2023-06-28.dtd +python3 -B -m paper_atlas catalogue dblp \ + --archive /data/paper-index/raw/dblp-2026-11-01.xml.gz \ + --dtd /data/paper-index/raw/dblp-2023-06-28.dtd \ + --out /data/paper-index/collected/dblp +``` + +`dblp-catalogue.sqlite` 保存论文集目录、按 crossref/年份/booktitle 的记录数量、各目录族数量及少量样本;`dblp-ai-candidates.jsonl` 保存候选会议族的原始索引元数据。存在多个出版卷字段的记录另存 `volume_anomalies`,保留全部值供归属核验;统计中仍只计一条,不任取第一个字段或展开成多篇。候选范围包含历史名称与 workshop,尚未赋予主会归属或录用状态。后续须依据具体论文集和届次核对目标范围、track 与出版标识,才能生成入库文件;不会把同一目录下的所有记录都视为主会。该盘点不下载 PDF,也不更改论文主库。 + +XML 只允许校验过的本地 DTD;官方通用名 `dblp.dtd` 与月度快照使用的 `dblp-2023-06-28.dtd` 均解析到同一份已校验文件。拒绝其他外部实体和内部 DTD 子集,不访问 XML 声明的任意网络地址,并遵循源 XML 的字符编码。临时目录库或 gzip 检查失败时不发布正式候选文件,需先调查残留的 `.part` 状态,再决定是否重新运行。 + +公开 ICLR 投稿快照的固定 Release 供后续筛选投稿和明确决定;它是第三方保存的 OpenReview 历史副本,不等于最新官方全量数据: + +```sh +python3 -B -m paper_atlas download \ + --url https://github.com/qhjqhj00/iclr-openreview-reviews/releases/download/v1.0/iclr.tar.bz2 \ + --sha256 350071daf25ebf1aef854ee1d5dc3a81834e1c468917c82a4f78af5548de34fc \ + --expected-bytes 512940722 \ + --output /data/paper-index/raw/iclr-openreview-v1.0.tar.bz2 +``` + +[快照来源与复现脚本](https://github.com/qhjqhj00/iclr-openreview-reviews)。原包包含评审回复;解析只筛选公开投稿的索引、标题、摘要、平台标签及带证据的明确决定,不提取评审正文、作者邮件或论文全文。必须核对原始 invitation、readers、签名及决定字段,不能根据评分或录用名单缺席推断拒稿。 + +优先处理近期三届,再单独补全 2020–2023;不以 2017 年的 PeerRead 样本代表近期拒稿覆盖: + +```sh +python3 -B -m paper_atlas collect iclr-snapshot \ + --archive /data/paper-index/raw/iclr-openreview-v1.0.tar.bz2 \ + --out /data/paper-index/collected --year 2024 --year 2025 --year 2026 +``` + +解析器先核验固定 SHA-256,再流式读取 tar,以临时 SQLite 按年份和 forum 关联投稿及决定,兼容回复先于投稿的归档顺序。只接受明确对 everyone 公开的投稿和具有会议主席签名的 Decision;保留原始决定值、note ID、决定日期、invitation 和签名证据。无明确决定的投稿保留 unknown,不产生拒稿推断。无法关联到公开投稿元数据的公开决定另存 unmatched-decisions 文件,供复核,不擅自创建论文。输出清单分别统计投稿状态、决定事件、缺失摘要和排除原因;重复副本去重,内容冲突会中止。导入时若同一编号指向不同 forum,或同一 forum 指向不同投稿编号,会报冲突并回滚,不强制合并。 + +该版本是 2026-05-08 发布的第三方历史副本,不代表平台当前状态或全部投稿;缺失决定及公开性筛选均需核验。采集成功也不等于主库已更新,后续应通过单写入协调器导入,并核对同届官方程序与公开决定的差异。 + +入库前按真实 forum 对照同届官方程序,生成逐年状态、名单缺口、历史决定差异和文本版本差异报告,以及带 SHA-256 的现有程序身份回填队列: + +```sh +python3 -B -m paper_atlas audit iclr --root /data/paper-index/collected \ + --year 2024 --year 2025 --year 2026 \ + --report /data/paper-index/collected/iclr-recent-audit.json +``` + +该检查要求清单和记录的届次、数量、摘要缺失统计一致,拒绝重复 forum、编号冲突及缺失公开决定证据。报告中的名单差异和 unknown 不会转换成拒稿。不同时间来源的决定保留各自证据,强 forum 标识负责关联同一次投稿;报告通过不等于平台全量覆盖或最终全库去重已经完成。生成的 `*-backfill-queue.json` 仅用于已入库程序记录的身份回填,快照文件仍须由协调器单独导入。 + +下载异常后先检查对应进程或 CI 任务确已终止,再使用同一参数增加 `--resume`。只有断点身份一致且服务器实际返回正确 Range 响应才续传;不支持时保留断点并报错,不自动重下。正式文件与下载清单均放在仓库外,不提交大型来源包。 + +### COLM 与 NeurIPS 官方摘要 + +```sh +python3 -B -m paper_atlas collect colm \ + --cache /data/paper-index/raw/http-cache --out /data/paper-index/collected +python3 -B -m paper_atlas collect neurips \ + --cache /data/paper-index/raw/http-cache --out /data/paper-index/collected +``` + +公共元数据请求对连接重置、超时及不完整响应最多尝试三次,保留原有有效缓存;HTTP 403 等访问拒绝会直接记录失败。声明 Content-Length 与实际响应不符时不发布缓存,已有缓存也受字节上限约束。旧页面没有赋值的 HTML 属性按空值处理,避免某一个空 class 属性阻断整届采集。 + +采集器从官方根目录发现实际发表届次,仅访问索引和 Abstract HTML 页面,不请求 PDF、补充材料或源码。可用重复 `--year` 限定实际存在的届次;不存在的年份会报错,局部重试保留其他届次的已有状态。每个来源串行请求,实际网络响应后等待 1 秒;缓存命中不额外等待。 + +COLM 当前目录提供 2024–2026 三届 pre-proceedings,按会议年份保存,保留平台实际出版日期和出版状态,不把晚补刊日期当作会议年份。NeurIPS 当前目录提供 1987–2025,并包含 2025 独立卷及 2021 独立 Datasets and Benchmarks 索引。主会、datasets_benchmarks、position、creative_ai 分开出文件,不混为同一个 track。跨平台自标识仅从出版方 paper-actions 区域链接与 citation 元数据提取,不把摘要里的引用链接当作当前论文 ID。manifest 的 `parser_version` 至少为 2 才允许入库;旧文件须利用缓存重新采集,避免错误身份合并。首次真实样本核对:COLM 2024 官方目录 299 个摘要入口;NeurIPS 1987 为 90 个,作者与摘要均已验证解析。样本成功不代表所有页面采集完成。 + +`colm-index-status.json`、`neurips-index-status.json` 保存逐索引状态;对应 `*-last-run.json` 保存本次实际选择范围。每个 JSONL 的 manifest 记录索引入口数、track 记录数和缺失摘要数。论文数量与官方页面声明不一致会失败,缺失摘要会标记 partial;不会把页脚、姓名政策或其他正文当作摘要。 + +这两个采集器只写采集文件,合并仍通过单写入协调流程。全量历史覆盖和所有未实现会议仍待最终核验。 + +### ACL Anthology 权威 XML + +```sh +python3 -B -m paper_atlas collect acl \ + --checkout /data/paper-index/raw/metadata-checkouts/acl-anthology \ + --out /data/paper-index/collected +``` + +`paper_atlas/io/git_metadata.py` 对官方仓库做只包含 `data/xml` 的 partial clone/sparse checkout,不获取 PDF,不执行仓库 hooks 或构建。每批固定到实际 Git commit。遍历 XML 的 volume venue 字段选择 ACL、EMNLP、NAACL、EACL、COLING、CoNLL;Findings 按所属会议独立成 track,不按文件名把其他同场 workshop 冒充主会。 + +新旧 Anthology ID 按官方规则构造,并核对源 DOI。保留 C69、D19-50 至 D19-66、W 系列的历史编号例外,排除编号 0 的前言。XML 混合标记通过 itertext 提取;无摘要保留为空并标记 partial。主会、Findings、workshop、demo、tutorial、industry、shared_task 分开。 + +联合举办会议保存一个会议事件及多个名称别名。例如 LREC-COLING 的论文可用 COLING 或 LREC 查询,仍只有一次该届投稿记录。可用 `--track main` 限定主会,避免把 Findings 或演示算入主会;别名数据在正常单写入合并时应用 schema。 + +`acl-collection-status.json` 记录每个 XML 及其 volume 的处理结果和排除原因;`acl-last-run.json` 记录源 commit 与本批范围。`--collection P17` 可限定一个确实存在的 collection。已验证真实 ACL 2017 长文 195 条、短文 107 条,二者摘要完整;其他 track 按源实际缺失情况记录,不能凭样本宣称整个历史完成。 + +### ICLR 官方公开程序元数据 + +```sh +python3 -B -m paper_atlas collect iclr-program --cache /data/paper-index/raw/http-cache \ + --out /data/paper-index/collected +# 入库后按官方主题检索;主题值以实际来源为准 +python3 -B -m paper_atlas query --db /data/paper-index/papers.sqlite \ + --venue ICLR --tag 'iclr_program:topic=Reinforcement Learning->Deep RL' +``` + +从 ICLR 官方年份导航登记届次,读取对应 Papers 页面明确引用的 orals-posters/abstracts JSON。核对声明数量、分页状态及唯一 event ID;只下载公开程序元数据,不访问用户资料、PDF、海报、幻灯片或视频。`--year` 可限制年份;清单中的已采集数与数据库已入库数分别统计。 + +官方程序中的 event 数不是论文数:同一篇文章可以有 poster 和 oral 两场展示。程序还会给负 sourceid 的 oral 展示生成占位 OpenReview 链接,这些链接不能作为真实论文标识。采集器核对原投稿编号、标题与作者后合并展示,优先使用正编号记录的真实论文链接;原展示编号和类型保留在源元数据中。 + +同一投稿编号的展示标题可能有小幅修订。只有负编号 oral 与正编号原稿对应、至少两位作者完全一致且标题相似度满足严格阈值时,才保留为同稿的标题变体;该规则仅在一届官方程序内部生效,不用于跨平台模糊合并。 + +只将 Conference 来源的论文视为 ICLR 主会录用,排除 Blog 和期刊转会展示,并在审计清单保存排除计数。2020/2021 旧数据允许依据官方主会 paper program 形成录用证据;无 forum ID 时保留 program 身份和匹配候选,不伪造标识。历史年度返回 404 会登记 unavailable,未来未发表届次登记 not_yet_published,其他访问或解析失败登记 blocked;这些状态都不能宣称该年已覆盖。 + +主题、关键词使用 `iclr_program:topic`、`iclr_program:keyword` 命名空间。通用导入器按源记录刷新标签证据,保留同一论文其他源记录及 `custom:*` 标签。每年的 JSONL、manifest、`iclr-program-year-status.json` 和 `iclr-program-last-run.json` 保存实际范围、独立展示数、主会展示数、论文数、缺失摘要与缺失 forum 数。录用程序不能提供拒稿全集,公开投稿及拒稿补充仍需独立来源。 + +### CVF / ECVA 视觉会议元数据 + +```sh +python3 -B -m paper_atlas collect cvf \ + --cache /data/paper-index/raw/http-cache --out /data/paper-index/collected +python3 -B -m paper_atlas collect ecva \ + --cache /data/paper-index/raw/http-cache --out /data/paper-index/collected +``` + +CVF 从官方根目录发现 CVPR、ICCV 主会与 Findings,分别保存 track;不把 workshop 或 WACV 算入这些会议。优先跟随页面明确提供的 All Papers 链接,避免只收录默认显示的第一天。ECVA 从官方总目录按论文 URL 的会议与年份分组,当前总目录列有 ECCV 2018、2020、2022、2024。不存在于来源的 `--venue`、`--year` 或二者组合会报错,不补造双年会议届次,也不能因此推断旧届次未举办。 + +只访问论文索引与摘要 HTML/PHP 页面。作者优先取 citation 元数据,旧页面尊重声明编码;无 citation 作者时取索引中的作者行,排除论文集出版信息。DOI 只取该论文条目所属的 DOI 链接或 citation 字段;冲突会失败,不从摘要引用中抽取当前论文 ID。不请求 PDF、补充材料、视频或代码。 + +每份清单记录索引入口数、论文数、缺失摘要与作者数,缺失内容保留为空并标记 partial。存在未被标题条目解析的论文链接时会失败,防止静默漏项。逐来源的 discovery、index-status、last-run 文件分别保存发现目录、逐届结果和实际请求范围。可重复使用 `--year` 或 `--venue` 限定重试;其他届次状态会保留。官方开放目录之外的早期届次、尚未刊出的新届次和独立出版方历史数据仍需后续补齐,采集器完成不代表全历史覆盖已完成。 + diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 0000000..c717686 --- /dev/null +++ b/docs/development.md @@ -0,0 +1,32 @@ +# 开发说明 + +## 模块边界 + +| 目录 | 职责 | +| --- | --- | +| `paper_atlas/cli.py` | 注册命令并延迟加载对应模块,统一参数入口 | +| `paper_atlas/collectors/` | 各来源的发现、解析与清单输出,不直接写论文主库 | +| `paper_atlas/io/` | 公共 HTTP、原始快照、HTML 树与受限 Git 元数据读取 | +| `paper_atlas/ingestion/` | 核对清单、事务导入、维护锁和来源身份回填 | +| `paper_atlas/db/` | 论文与投稿身份解析、来源保留、合并审计和 schema | +| `paper_atlas/audits/` | 入库前来源对照及全部目标的覆盖报告 | +| `paper_atlas/querying.py` | 一致性只读快照上的查询 | +| `paper_atlas/resources/` | 随安装包提供的候选会议目录 | + +采集器依赖公共 IO,导入器依赖存储层,查询和审计读取既有证据。不要让公共网络模块反向依赖某个采集器,也不要在 CLI 中嵌入来源解析逻辑。新增采集器需要注册命令、输出带来源/届次/track 的清单,并补充有意义的来源边界验证。 + +## 远程验证 + +所有测试、构建、打包及安装验证在确认的 `ces-control:host` 环境进行。本机只编辑源码和进行不触发构建的检查。 + +```sh +python3 -B -m unittest discover -s tests -t . -v +``` + +`.gitea/workflows/check.yml` 会运行回归测试,构建并安装 wheel,然后从源码目录之外检查 console 命令、模块入口、schema 和会议目录资源。测试中只使用独立临时库,不连接运行中的论文主库。 + +## 目录迁移 + +原根目录脚本已迁入对应模块,统一使用 `paper-atlas ` 或 `python -m paper_atlas `。不再把脚本文件路径作为接口。SQL schema、来源 JSONL、manifest 及既有数据库结构未因这次封装改变。 + +部署使用固定 commit。仍在运行的旧批次继续使用已部署版本,待该批次完成后再切换后续任务;不要为了目录迁移重启已完成的全量导入。 diff --git a/docs/operations.md b/docs/operations.md new file mode 100644 index 0000000..7ee7787 --- /dev/null +++ b/docs/operations.md @@ -0,0 +1,21 @@ +# 运行维护与数据核验 + +代码以固定 commit 部署,已经启动的任务继续使用其原版本。目录重构不要求迁移数据库,也不更改已有 JSONL 和 manifest 格式。 + +## 长期维护与更新 + +1. 每次采集先核对来源、许可、可访问性、空闲空间和现有运行批次。保持低并发,遵守来源限流、重试与人机验证要求。 +2. 下载先写 `.part`,确认完整 HTTP 响应和 ZIP 目录,再记录 SHA-256 与时间后切换为正式文件。正式导入会读至 EOF,从而验证成员 CRC。 +3. 每次导入保留来源证据和批次,按会议/年份/track 报告覆盖状态。缺失摘要留空,不编造文本;缺失决定保留未知。 +4. 核验行数、唯一标识、外键、全文查询和 SQLite 完整性后,再将批次视为交付。样本通过不能证明全量正确。 +5. 数据库备份使用 SQLite backup API 或一致性快照,不能在写入过程中只复制 `.sqlite` 主文件而遗漏 WAL。按明确保留策略轮换备份,避免无上限积累。 +6. 不为每次数据更新生成新 Docker 镜像;不常驻下载器;不自动创建定时任务。本项目的更新任务应按需或经用户授权调度。 +7. 代码通过工作分支和 PR 交付;构建或 CI 只能使用用户确认的远程 runner,不在本机触发构建。 + +## 当前访问限制 + +2026-11-06 从目标服务器测试:Kaggle 元数据 GET 可下载;OpenReview 会议 group 接口可读,但 notes 接口返回 `ChallengeRequiredError`。这限制了直接补全;已使用来源可追溯的公开历史快照补充近期投稿与明确决定。仍不能用录用名单替代全部投稿,也不能声称覆盖了全部拒稿。 + +已核验的采集文件计数:ICLR 2020–2026 主会 15,534 条、COLM 2024–2026 共 1,573 条,摘要均已取得;候选范围内的 PMLR 文件为 31,428 条,已剔除两份重复出版元数据。ACL Anthology 已选文件为 63,297 条,其中 15,147 条源记录缺摘要。这些是采集文件的来源记录数,不能相加当作去重论文数,也不能代替主库实时统计;实际入库量使用 paper-atlas query --stats 查看。 + +代码在 `codex/metadata-index` 工作分支交付,[Draft PR #1](https://gitea.entroia.com/Zayden-Jung/paper-atlas/pulls/1) 尚未合并,等待全量就绪后用户手动合并。各提交分别由远程 CI 验证,结果以对应提交的 Gitea Actions 检查为准。全量导入、其他会议适配器、去重复核和覆盖率核验继续进行,应随实际结果更新此说明。 diff --git a/docs/usage.md b/docs/usage.md new file mode 100644 index 0000000..bde3024 --- /dev/null +++ b/docs/usage.md @@ -0,0 +1,45 @@ +# 使用指南 + +先在已确认的远程环境安装项目,使用 `paper-atlas`。从源码运行时,将命令前缀替换为 `python3 -B -m paper_atlas`,其余参数相同。数据目录位于仓库外。 + +## 1. 下载固定快照 + +`paper-atlas download --help` 说明大小、校验和与续传参数。各来源的固定版本与具体命令见[数据源说明](data-sources.md)。下载成功只表示原始文件已核验。 + +## 2. 采集并核对清单 + +```sh +paper-atlas collect pmlr --cache /data/paper-index/raw/http-cache --out /data/paper-index/collected +paper-atlas collect cvf --cache /data/paper-index/raw/http-cache --out /data/paper-index/collected +paper-atlas collect ecva --cache /data/paper-index/raw/http-cache --out /data/paper-index/collected +paper-atlas catalogue dblp --help +paper-atlas audit iclr --help +``` + +采集输出 JSONL、manifest 和逐卷或逐年 catalogue。逐项检查 blocked、partial、缺失摘要及目标会议归属。DBLP 候选目录还需核验届次和 track,不能直接当作主会录用名单。 + +## 3. 串行导入 + +首次 arXiv 导入使用 `paper-atlas import arxiv --db ... --archive ...`。现有数据库合并已核验的会议文件使用: + +```sh +paper-atlas maintain merge --db /data/paper-index/papers.sqlite \ + --archive /data/paper-index/raw/arxiv-metadata.zip \ + /data/paper-index/collected/selected-file.jsonl +``` + +上例的 selected-file 须换成实际通过核验的文件。协调器会维护本项目的 arXiv 断点、序列化写入并检查外键;已完成的 arXiv 批次不会重启。持久批次应作为一次性 systemd 任务运行,详见[运行维护](operations.md)。 + +## 4. 查询与分析前检查 + +```sh +paper-atlas query --db /data/paper-index/papers.sqlite --query '"language model"' --limit 20 +paper-atlas query --db /data/paper-index/papers.sqlite --tag arxiv:category=cs.AI +paper-atlas query --db /data/paper-index/papers.sqlite --venue ICLR --year 2025 --status rejected +paper-atlas query --db /data/paper-index/papers.sqlite --paper 123 +paper-atlas coverage --db /data/paper-index/papers.sqlite --out /data/paper-index/collected/coverage.json +``` + +`--status rejected` 筛选某次投稿的最新已知决定。它并不表示论文在所有会议均未录用;分析转投情况时查看该论文的完整投稿历史。年份表示会议届次,观察时间和决定日期分别保留。 + +`--stats` 中 papers 是当前规则下的论文实体数;source_records 是来源记录数;decisions 是决定观察数。一个投稿可能有多个来源和多条观察,不能相加当作论文数。 diff --git a/paper_atlas/__init__.py b/paper_atlas/__init__.py new file mode 100644 index 0000000..0db9037 --- /dev/null +++ b/paper_atlas/__init__.py @@ -0,0 +1,3 @@ +"""Paper Atlas research metadata toolkit.""" + +__version__ = '0.1.0' diff --git a/paper_atlas/__main__.py b/paper_atlas/__main__.py new file mode 100644 index 0000000..6db6053 --- /dev/null +++ b/paper_atlas/__main__.py @@ -0,0 +1,3 @@ +from paper_atlas.cli import main + +raise SystemExit(main()) diff --git a/paper_atlas/audits/__init__.py b/paper_atlas/audits/__init__.py new file mode 100644 index 0000000..e55afaf --- /dev/null +++ b/paper_atlas/audits/__init__.py @@ -0,0 +1 @@ +"""Audits.""" diff --git a/coverage_report.py b/paper_atlas/audits/coverage.py similarity index 84% rename from coverage_report.py rename to paper_atlas/audits/coverage.py index 11cdbac..8550881 100644 --- a/coverage_report.py +++ b/paper_atlas/audits/coverage.py @@ -1,10 +1,11 @@ """Show every target venue, including those with no imported records.""" import argparse +from paper_atlas.config import DEFAULT_REGISTRY import json from pathlib import Path import sqlite3 -from submission_evidence import timestamp +from paper_atlas.db.submissions import timestamp def report(db,registry): @@ -32,10 +33,14 @@ def report(db,registry): finally:c.close() -if __name__=='__main__': - parser=argparse.ArgumentParser();parser.add_argument('--db',required=True) - parser.add_argument('--registry',default=str(Path(__file__).with_name('venues.json')));parser.add_argument('--out') - args=parser.parse_args();result=report(args.db,args.registry);text=json.dumps(result,ensure_ascii=False,indent=2) +def main(argv=None, *, prog=None): + parser=argparse.ArgumentParser(prog=prog);parser.add_argument('--db',required=True) + parser.add_argument('--registry',default=str(DEFAULT_REGISTRY));parser.add_argument('--out') + args=parser.parse_args(argv);result=report(args.db,args.registry);text=json.dumps(result,ensure_ascii=False,indent=2) if args.out: path=Path(args.out);stage=path.with_suffix('.part');stage.write_text(text+'\n');stage.replace(path) else:print(text) + + +if __name__ == '__main__': + main() diff --git a/audit_iclr_snapshot.py b/paper_atlas/audits/iclr.py similarity index 94% rename from audit_iclr_snapshot.py rename to paper_atlas/audits/iclr.py index 8501d78..da6cdff 100644 --- a/audit_iclr_snapshot.py +++ b/paper_atlas/audits/iclr.py @@ -5,8 +5,8 @@ import hashlib import json from pathlib import Path -from index_core import author_keys,normalized -from submission_evidence import identities_for_row,timestamp +from paper_atlas.db.store import author_keys,normalized +from paper_atlas.db.submissions import identities_for_row,timestamp def load(path,source,year): @@ -85,7 +85,11 @@ def audit(root,years,report_path): return result -if __name__=='__main__': - parser=argparse.ArgumentParser();parser.add_argument('--root',required=True);parser.add_argument('--report',required=True) - parser.add_argument('--year',type=int,action='append',required=True);args=parser.parse_args() +def main(argv=None, *, prog=None): + parser=argparse.ArgumentParser(prog=prog);parser.add_argument('--root',required=True);parser.add_argument('--report',required=True) + parser.add_argument('--year',type=int,action='append',required=True);args=parser.parse_args(argv) audit(args.root,args.year,args.report) + + +if __name__ == '__main__': + main() diff --git a/paper_atlas/cli.py b/paper_atlas/cli.py new file mode 100644 index 0000000..8c4519b --- /dev/null +++ b/paper_atlas/cli.py @@ -0,0 +1,45 @@ +"""One command tree; adapters are imported only when their command is selected.""" +import argparse +from importlib import import_module + +from paper_atlas import __version__ + + +COMMANDS = { + ('query',): ('paper_atlas.querying', {}, '查询论文、标签和投稿历史'), + ('coverage',): ('paper_atlas.audits.coverage', {}, '查看全部候选会议的覆盖情况'), + ('download',): ('paper_atlas.io.snapshots', {}, '下载并校验固定版本快照'), + ('collect','pmlr'): ('paper_atlas.collectors.pmlr', {}, 'PMLR 官方元数据'), + ('collect','acl'): ('paper_atlas.collectors.acl', {}, 'ACL Anthology XML'), + ('collect','iclr-program'): ('paper_atlas.collectors.iclr', {}, 'ICLR 官方公开程序'), + ('collect','iclr-snapshot'): ('paper_atlas.collectors.openreview', {}, 'ICLR 公开投稿与决定快照'), + ('collect','peerread'): ('paper_atlas.collectors.peerread', {}, 'PeerRead 历史决定补充'), + ('collect','colm'): ('paper_atlas.collectors.proceedings', {'source':'colm'}, 'COLM 官方论文集'), + ('collect','neurips'): ('paper_atlas.collectors.proceedings', {'source':'neurips'}, 'NeurIPS 官方论文集'), + ('collect','cvf'): ('paper_atlas.collectors.vision', {'source':'cvf'}, 'CVPR / ICCV 官方开放目录'), + ('collect','ecva'): ('paper_atlas.collectors.vision', {'source':'ecva'}, 'ECCV 官方开放目录'), + ('catalogue','dblp'): ('paper_atlas.collectors.dblp', {}, '盘点 DBLP 历史会议目录'), + ('import','arxiv'): ('paper_atlas.ingestion.arxiv', {}, '导入 arXiv 元数据 ZIP'), + ('import','records'): ('paper_atlas.ingestion.records', {}, '导入核验过的会议文件'), + ('maintain','merge'): ('paper_atlas.ingestion.maintenance', {}, '协调单写入合并与恢复'), + ('maintain','backfill'): ('paper_atlas.ingestion.backfill', {}, '回填既有投稿的来源身份'), + ('audit','iclr'): ('paper_atlas.audits.iclr', {}, '核对 ICLR 快照与同届官方程序'), +} + + +def main(argv=None): + parser=argparse.ArgumentParser(prog='paper-atlas',description='Paper Atlas · 论文元数据与公开投稿历史') + parser.add_argument('--version',action='version',version='paper-atlas '+__version__) + commands=parser.add_subparsers(dest='command',required=True);groups={} + for route,(module,kwargs,help_text) in COMMANDS.items(): + if len(route)==1: + leaf=commands.add_parser(route[0],help=help_text,add_help=False) + else: + if route[0] not in groups: + group=commands.add_parser(route[0],help={'collect':'采集来源元数据','catalogue':'盘点历史目录','import':'导入数据库','maintain':'维护数据库','audit':'核验数据'}[route[0]]) + groups[route[0]]=group.add_subparsers(dest='operation',required=True) + leaf=groups[route[0]].add_parser(route[1],help=help_text,add_help=False) + leaf.set_defaults(handler=(route,module,kwargs)) + args,forwarded=parser.parse_known_args(argv) + route,module,kwargs=args.handler + return import_module(module).main(forwarded,prog='paper-atlas '+' '.join(route),**kwargs) diff --git a/paper_atlas/collectors/__init__.py b/paper_atlas/collectors/__init__.py new file mode 100644 index 0000000..4856e8b --- /dev/null +++ b/paper_atlas/collectors/__init__.py @@ -0,0 +1 @@ +"""Collectors.""" diff --git a/collect_acl.py b/paper_atlas/collectors/acl.py similarity index 97% rename from collect_acl.py rename to paper_atlas/collectors/acl.py index cfd9976..8e53f18 100644 --- a/collect_acl.py +++ b/paper_atlas/collectors/acl.py @@ -5,7 +5,7 @@ import re import xml.etree.ElementTree as ET from pathlib import Path -from metadata_checkout import checkout_metadata +from paper_atlas.io.git_metadata import checkout_metadata VENUES={'acl':'ACL','emnlp':'EMNLP','naacl':'NAACL','eacl':'EACL','coling':'COLING','conll':'CoNLL'} @@ -100,12 +100,12 @@ def collect_xml(path,out,commit): return results -if __name__=='__main__': - parser=argparse.ArgumentParser() +def main(argv=None, *, prog=None): + parser=argparse.ArgumentParser(prog=prog) parser.add_argument('--checkout',required=True) parser.add_argument('--out',required=True) parser.add_argument('--collection',action='append') - args=parser.parse_args() + args=parser.parse_args(argv) commit=checkout_metadata('https://github.com/acl-org/acl-anthology.git',args.checkout,['/data/xml/']) out=Path(args.out);out.mkdir(parents=True,exist_ok=True) status_file=out/'acl-collection-status.json' @@ -129,3 +129,7 @@ if __name__=='__main__': part=status_file.with_suffix('.part');part.write_text(json.dumps(catalogue,indent=2));part.replace(status_file) (out/'acl-last-run.json').write_text(json.dumps({'metadata_commit':commit,'collection_filter':args.collection,'blocked_collections':errors},indent=2)) if errors:raise SystemExit(1) + + +if __name__ == '__main__': + main() diff --git a/catalogue_dblp.py b/paper_atlas/collectors/dblp.py similarity index 95% rename from catalogue_dblp.py rename to paper_atlas/collectors/dblp.py index 6d7bb16..702cc3c 100644 --- a/catalogue_dblp.py +++ b/paper_atlas/collectors/dblp.py @@ -11,8 +11,8 @@ from pathlib import Path import sqlite3 from xml.parsers import expat -from download_snapshot import digest_file -from submission_evidence import timestamp +from paper_atlas.io.snapshots import digest_file +from paper_atlas.db.submissions import timestamp XML_SHA='0376ad019b216553faff0a14e1757b2d4c7a3b84c1beacf0511ef83695211b18' @@ -136,6 +136,10 @@ def catalogue(archive,dtd,out,expected_sha=XML_SHA,expected_dtd_md5=DTD_MD5): if c is not None:c.close() -if __name__=='__main__': - parser=argparse.ArgumentParser();parser.add_argument('--archive',required=True);parser.add_argument('--dtd',required=True);parser.add_argument('--out',required=True) - args=parser.parse_args();catalogue(args.archive,args.dtd,args.out) +def main(argv=None, *, prog=None): + parser=argparse.ArgumentParser(prog=prog);parser.add_argument('--archive',required=True);parser.add_argument('--dtd',required=True);parser.add_argument('--out',required=True) + args=parser.parse_args(argv);catalogue(args.archive,args.dtd,args.out) + + +if __name__ == '__main__': + main() diff --git a/collect_iclr.py b/paper_atlas/collectors/iclr.py similarity index 98% rename from collect_iclr.py rename to paper_atlas/collectors/iclr.py index 6eb4eb0..b6a7610 100644 --- a/collect_iclr.py +++ b/paper_atlas/collectors/iclr.py @@ -9,9 +9,9 @@ import re from urllib.error import HTTPError from urllib.parse import parse_qs, urljoin, urlsplit -from collect_pmlr import fetch -from collect_proceedings import Document, clean -from index_core import author_keys, normalized +from paper_atlas.io.http import fetch +from paper_atlas.io.html import Document, clean +from paper_atlas.db.store import author_keys, normalized ROOT = 'https://iclr.cc/' SOURCE = 'iclr_program' @@ -208,12 +208,12 @@ def collect_year(year, cache, out): return manifest -def main(): - parser = argparse.ArgumentParser() +def main(argv=None, *, prog=None): + parser = argparse.ArgumentParser(prog=prog) parser.add_argument('--cache', required=True) parser.add_argument('--out', required=True) parser.add_argument('--year', type=int, action='append') - args = parser.parse_args() + args = parser.parse_args(argv) out = Path(args.out); out.mkdir(parents=True, exist_ok=True) years = discover(fetch(ROOT, args.cache, 4*1024**2, delay=1).decode()) if args.year and set(args.year)-{year for year, _ in years}: diff --git a/collect_openreview_snapshot.py b/paper_atlas/collectors/openreview.py similarity index 98% rename from collect_openreview_snapshot.py rename to paper_atlas/collectors/openreview.py index b2c3302..e66e887 100644 --- a/collect_openreview_snapshot.py +++ b/paper_atlas/collectors/openreview.py @@ -10,7 +10,7 @@ import sqlite3 import tarfile import tempfile -from download_snapshot import digest_file +from paper_atlas.io.snapshots import digest_file SOURCE='openreview_snapshot' @@ -194,7 +194,11 @@ def collect(archive,out,years,expected_sha=SHA256): return {year:catalogue[str(year)] for year in years} -if __name__=='__main__': - parser=argparse.ArgumentParser();parser.add_argument('--archive',required=True);parser.add_argument('--out',required=True) - parser.add_argument('--year',type=int,action='append');args=parser.parse_args() +def main(argv=None, *, prog=None): + parser=argparse.ArgumentParser(prog=prog);parser.add_argument('--archive',required=True);parser.add_argument('--out',required=True) + parser.add_argument('--year',type=int,action='append');args=parser.parse_args(argv) collect(args.archive,args.out,args.year or sorted(EXPECTED)) + + +if __name__ == '__main__': + main() diff --git a/collect_peerread.py b/paper_atlas/collectors/peerread.py similarity index 95% rename from collect_peerread.py rename to paper_atlas/collectors/peerread.py index ca18641..e406dfe 100644 --- a/collect_peerread.py +++ b/paper_atlas/collectors/peerread.py @@ -9,7 +9,7 @@ import urllib.request from collections import defaultdict from pathlib import Path -from collect_pmlr import fetch +from paper_atlas.io.http import fetch def collect(cache,out): @@ -63,9 +63,13 @@ def collect(cache,out): print(json.dumps(manifest),flush=True) -if __name__=='__main__': - parser = argparse.ArgumentParser() +def main(argv=None, *, prog=None): + parser = argparse.ArgumentParser(prog=prog) parser.add_argument('--cache',required=True) parser.add_argument('--out',required=True) - args = parser.parse_args() + args = parser.parse_args(argv) collect(args.cache,args.out) + + +if __name__ == '__main__': + main() diff --git a/collect_pmlr.py b/paper_atlas/collectors/pmlr.py similarity index 79% rename from collect_pmlr.py rename to paper_atlas/collectors/pmlr.py index a92422f..f76f8a0 100644 --- a/collect_pmlr.py +++ b/paper_atlas/collectors/pmlr.py @@ -1,17 +1,13 @@ """Collect official PMLR metadata, never the PDFs linked by the metadata.""" import argparse -import hashlib -from http.client import IncompleteRead +from paper_atlas.config import DEFAULT_REGISTRY import io import json import re import os import subprocess -import ssl import tarfile import time -import urllib.error -import urllib.request from pathlib import Path import yaml @@ -40,45 +36,7 @@ def venue_key(shortname): return re.sub(r'[\s_-]*(?:19|20)\d{2}$','',str(shortname).strip().upper()) -def fetch(url,cache,limit=64*1024**2,delay=0.5): - cache = Path(cache) - cache.mkdir(parents=True,exist_ok=True) - path = cache/hashlib.sha256(url.encode()).hexdigest() - if path.exists() and time.time()-path.stat().st_mtime < 24*3600: - if path.stat().st_size>limit:raise RuntimeError('Metadata response exceeds byte limit') - return path.read_bytes() - for attempt in range(3): - try: - request = urllib.request.Request(url,headers={'User-Agent':'PaperAtlas/0.1 (public scholarly metadata research)'}) - with urllib.request.urlopen(request,timeout=90) as response: - if response.status != 200: - raise RuntimeError('Expected complete HTTP 200 response') - expected=int(response.headers.get('Content-Length','0')) - if expected>limit: - raise RuntimeError('Metadata response exceeds byte limit') - data = response.read(limit+1) - if len(data)>limit: - raise RuntimeError('Metadata response exceeds byte limit') - if expected and len(data)!=expected: - raise IncompleteRead(data,max(0,expected-len(data))) - part = path.with_suffix('.part') - part.write_bytes(data) - part.replace(path) - path.with_suffix('.json').write_text(json.dumps({'url':url,'sha256':hashlib.sha256(data).hexdigest(),'bytes':len(data),'retrieved_at':time.strftime('%Y-%m-%dT%H:%M:%SZ',time.gmtime())})) - time.sleep(delay) - return data - except urllib.error.HTTPError as exc: - if exc.code not in (429,500,502,503,504) or attempt==2: - exc.close() - raise - retry_after=exc.headers.get('Retry-After','1');exc.close() - try:retry_after=int(retry_after) - except ValueError:retry_after=5 - time.sleep(min(30,max(2**attempt,retry_after))) - except (urllib.error.URLError,TimeoutError,ConnectionError,IncompleteRead,ssl.SSLError) as exc: - reason=getattr(exc,'reason',exc) - if isinstance(reason,ssl.SSLCertVerificationError) or attempt==2:raise - time.sleep(min(30,2**(attempt+1))) +from paper_atlas.io.http import fetch def metadata_texts(volume,cache): @@ -194,13 +152,13 @@ def collect_volume(volume,cache,out,allowed=None): return result -def main(): - parser = argparse.ArgumentParser() +def main(argv=None, *, prog=None): + parser = argparse.ArgumentParser(prog=prog) parser.add_argument('--cache',required=True) parser.add_argument('--out',required=True) parser.add_argument('--volume',type=int,action='append') - parser.add_argument('--registry',default=str(Path(__file__).with_name('venues.json'))) - args = parser.parse_args() + parser.add_argument('--registry',default=str(DEFAULT_REGISTRY)) + args = parser.parse_args(argv) allowed = {venue_key(v['acronym']) for v in json.loads(Path(args.registry).read_text())['venues']} volumes = args.volume if not volumes: diff --git a/collect_proceedings.py b/paper_atlas/collectors/proceedings.py similarity index 83% rename from collect_proceedings.py rename to paper_atlas/collectors/proceedings.py index 6c19bf3..195e8c5 100644 --- a/collect_proceedings.py +++ b/paper_atlas/collectors/proceedings.py @@ -4,61 +4,15 @@ import json import re import time from collections import defaultdict -from html.parser import HTMLParser from pathlib import Path from urllib.parse import urljoin,urlsplit -from collect_pmlr import fetch +from paper_atlas.io.http import fetch ROOTS={'colm':'https://proceedings.colm.cc/','neurips':'https://proceedings.neurips.cc/'} -class Node: - def __init__(self,tag='',attrs=None): - self.tag=tag - self.attrs={key:value or '' for key,value in attrs or []} - self.children=[] - - def text(self): - if self.tag in ('script','style'): - return '' - content=''.join(child.text() if isinstance(child,Node) else child for child in self.children) - return ' '+content+' ' if self.tag in ('p','div','section','li','br') else content - - def walk(self): - yield self - for child in self.children: - if isinstance(child,Node): - yield from child.walk() - - -class Document(HTMLParser): - VOID={'area','base','br','col','embed','hr','img','input','link','meta','param','source','track','wbr'} - - def __init__(self,html): - super().__init__(convert_charrefs=True) - self.root=Node() - self.stack=[self.root] - self.feed(html) - - def handle_starttag(self,tag,attrs): - node=Node(tag,attrs) - self.stack[-1].children.append(node) - if tag not in self.VOID: - self.stack.append(node) - - def handle_endtag(self,tag): - for index in range(len(self.stack)-1,0,-1): - if self.stack[index].tag==tag: - del self.stack[index:] - break - - def handle_data(self,data): - self.stack[-1].children.append(data) - - -def clean(text): - return ' '.join(text.split()) +from paper_atlas.io.html import Document,Node,clean def track_key(text): @@ -188,13 +142,14 @@ def collect_index(source,index_url,year,cache,out): return results -if __name__=='__main__': - parser=argparse.ArgumentParser() - parser.add_argument('--source',choices=ROOTS,required=True) +def main(argv=None, *, prog=None, source=None): + parser=argparse.ArgumentParser(prog=prog) + if source is None:parser.add_argument('--source',choices=ROOTS,required=True) + else:parser.set_defaults(source=source) parser.add_argument('--cache',required=True) parser.add_argument('--out',required=True) parser.add_argument('--year',type=int,action='append') - args=parser.parse_args() + args=parser.parse_args(argv) out=Path(args.out);out.mkdir(parents=True,exist_ok=True) status_path=out/f'{args.source}-index-status.json' catalogue=json.loads(status_path.read_text()) if status_path.exists() else {} @@ -214,3 +169,7 @@ if __name__=='__main__': part=status_path.with_suffix('.part');part.write_text(json.dumps(catalogue,indent=2));part.replace(status_path) (out/f'{args.source}-last-run.json').write_text(json.dumps({'requested_indexes':requested,'year_filter':args.year},indent=2)) if any(catalogue[url]['status']=='blocked' for url in requested):raise SystemExit(1) + + +if __name__ == '__main__': + main() diff --git a/collect_vision.py b/paper_atlas/collectors/vision.py similarity index 96% rename from collect_vision.py rename to paper_atlas/collectors/vision.py index 9709222..1247148 100644 --- a/collect_vision.py +++ b/paper_atlas/collectors/vision.py @@ -7,8 +7,8 @@ import re import time from urllib.parse import parse_qs, unquote, urljoin, urlsplit -from collect_pmlr import fetch -from collect_proceedings import Document, Node, clean +from paper_atlas.io.http import fetch +from paper_atlas.io.html import Document, Node, clean ROOTS={'cvf':'https://openaccess.thecvf.com/','ecva':'https://www.ecva.net/papers.php'} HOSTS={'openaccess.thecvf.com','www.ecva.net','ecva.net'} @@ -159,11 +159,13 @@ def collect_index(source,index,cache,out): return manifest -def main(): - parser=argparse.ArgumentParser();parser.add_argument('--source',choices=ROOTS,required=True) +def main(argv=None, *, prog=None, source=None): + parser=argparse.ArgumentParser(prog=prog) + if source is None:parser.add_argument('--source',choices=ROOTS,required=True) + else:parser.set_defaults(source=source) parser.add_argument('--cache',required=True);parser.add_argument('--out',required=True) parser.add_argument('--year',type=int,action='append');parser.add_argument('--venue',choices=['CVPR','ICCV','ECCV'],action='append') - args=parser.parse_args();out=Path(args.out);out.mkdir(parents=True,exist_ok=True) + args=parser.parse_args(argv);out=Path(args.out);out.mkdir(parents=True,exist_ok=True) indexes=discover(args.source,args.cache) if args.year and set(args.year)-{r['year'] for r in indexes}:parser.error('Requested year is absent from the official source') if args.venue and set(args.venue)-{r['venue'] for r in indexes}:parser.error('Requested venue is absent from this source') diff --git a/paper_atlas/config.py b/paper_atlas/config.py new file mode 100644 index 0000000..f771b97 --- /dev/null +++ b/paper_atlas/config.py @@ -0,0 +1,4 @@ +"""Read-only resources shipped with the installed package.""" +from importlib.resources import files + +DEFAULT_REGISTRY = files('paper_atlas.resources').joinpath('venues.json') diff --git a/paper_atlas/db/__init__.py b/paper_atlas/db/__init__.py new file mode 100644 index 0000000..937b856 --- /dev/null +++ b/paper_atlas/db/__init__.py @@ -0,0 +1 @@ +"""Db.""" diff --git a/schema.sql b/paper_atlas/db/schema.sql similarity index 100% rename from schema.sql rename to paper_atlas/db/schema.sql diff --git a/index_core.py b/paper_atlas/db/store.py similarity index 97% rename from index_core.py rename to paper_atlas/db/store.py index df7ce7f..11a8ca0 100644 --- a/index_core.py +++ b/paper_atlas/db/store.py @@ -1,10 +1,11 @@ """Shared identity resolution and provenance-preserving metadata storage.""" import json +from importlib.resources import files import re import sqlite3 import unicodedata from pathlib import Path -from submission_evidence import attach_submission,migrate_sources,write_decision +from paper_atlas.db.submissions import attach_submission,migrate_sources,write_decision def normalized(value): @@ -29,7 +30,7 @@ def author_keys(authors): class Store: def __init__(self,path): self.conn = sqlite3.connect(path,timeout=60) - self.conn.executescript(Path(__file__).with_name('schema.sql').read_text()) + self.conn.executescript(files('paper_atlas.db').joinpath('schema.sql').read_text()) self.conn.execute('PRAGMA synchronous=NORMAL') self.conn.execute('PRAGMA cache_size=-32768') migrate_sources(self.conn) @@ -146,7 +147,7 @@ class Store: paper=c.execute('SELECT paper_id FROM source_records WHERE id=?',(record,)).fetchone()[0] submission=attach_submission(self,edition,paper,record,external_id,identities or []) if submitted_at: - from submission_evidence import timestamp + from paper_atlas.db.submissions import timestamp c.execute('UPDATE submissions SET submitted_at=COALESCE(submitted_at,?) WHERE id=?',(timestamp(submitted_at),submission)) if status is not None:write_decision(self,submission,record,status,raw_value,url,observed_at,decision_date,source_decision_id) return edition diff --git a/submission_evidence.py b/paper_atlas/db/submissions.py similarity index 100% rename from submission_evidence.py rename to paper_atlas/db/submissions.py diff --git a/paper_atlas/ingestion/__init__.py b/paper_atlas/ingestion/__init__.py new file mode 100644 index 0000000..3485d49 --- /dev/null +++ b/paper_atlas/ingestion/__init__.py @@ -0,0 +1 @@ +"""Ingestion.""" diff --git a/import_arxiv.py b/paper_atlas/ingestion/arxiv.py similarity index 96% rename from import_arxiv.py rename to paper_atlas/ingestion/arxiv.py index 8e71dfd..8ca497d 100644 --- a/import_arxiv.py +++ b/paper_atlas/ingestion/arxiv.py @@ -7,7 +7,7 @@ import zipfile from pathlib import Path -from index_core import Store +from paper_atlas.db.store import Store def ingest(db, archive): @@ -70,9 +70,13 @@ def ingest(db, archive): conn.close() -if __name__ == '__main__': - parser = argparse.ArgumentParser() +def main(argv=None, *, prog=None): + parser = argparse.ArgumentParser(prog=prog) parser.add_argument('--db', required=True) parser.add_argument('--archive', required=True) - args = parser.parse_args() + args = parser.parse_args(argv) ingest(args.db,args.archive) + + +if __name__ == '__main__': + main() diff --git a/backfill_submission_evidence.py b/paper_atlas/ingestion/backfill.py similarity index 90% rename from backfill_submission_evidence.py rename to paper_atlas/ingestion/backfill.py index 6f84430..bee0ebf 100644 --- a/backfill_submission_evidence.py +++ b/paper_atlas/ingestion/backfill.py @@ -4,8 +4,8 @@ import hashlib import json from pathlib import Path -from index_core import Store -from submission_evidence import attach_submission,identities_for_row +from paper_atlas.db.store import Store +from paper_atlas.db.submissions import attach_submission,identities_for_row def queue_files(path): @@ -58,6 +58,10 @@ def backfill(db,files): finally:c.close() -if __name__=='__main__': - parser=argparse.ArgumentParser();parser.add_argument('--db',required=True);parser.add_argument('--queue',required=True) - args=parser.parse_args();backfill(args.db,queue_files(args.queue)) +def main(argv=None, *, prog=None): + parser=argparse.ArgumentParser(prog=prog);parser.add_argument('--db',required=True);parser.add_argument('--queue',required=True) + args=parser.parse_args(argv);backfill(args.db,queue_files(args.queue)) + + +if __name__ == '__main__': + main() diff --git a/merge_conferences.py b/paper_atlas/ingestion/maintenance.py similarity index 84% rename from merge_conferences.py rename to paper_atlas/ingestion/maintenance.py index 0d07a41..829f5b8 100644 --- a/merge_conferences.py +++ b/paper_atlas/ingestion/maintenance.py @@ -9,11 +9,12 @@ import json import fcntl import sqlite3 import subprocess +import sys from pathlib import Path -from import_records import ingest_file -from index_core import Store -from backfill_submission_evidence import backfill,queue_files +from paper_atlas.ingestion.records import ingest_file +from paper_atlas.db.store import Store +from paper_atlas.ingestion.backfill import backfill,queue_files UNIT='paper-atlas-arxiv-import.service' @@ -65,7 +66,7 @@ def merge_locked(db,archive,files,backfill_queue=None): if property_value('LoadState')=='loaded': subprocess.run(['systemctl','start',UNIT],check=True) else: - subprocess.run(['systemd-run','--unit='+UNIT,'--property=WorkingDirectory='+str(Path(__file__).parent),'--property=CPUQuota=50%','--property=MemoryMax=512M','--property=Nice=15','--property=IOWeight=10','--property=StandardOutput=append:/data/paper-index/arxiv-import.log','--property=StandardError=append:/data/paper-index/arxiv-import.log','/usr/bin/env','PYTHONDONTWRITEBYTECODE=1','python3','-B',str(Path(__file__).with_name('import_arxiv.py')),'--db',db,'--archive',archive],check=True) + subprocess.run(['systemd-run','--unit='+UNIT,'--property=WorkingDirectory='+str(Path(__file__).resolve().parents[2]),'--property=CPUQuota=50%','--property=MemoryMax=512M','--property=Nice=15','--property=IOWeight=10','--property=StandardOutput=append:/data/paper-index/arxiv-import.log','--property=StandardError=append:/data/paper-index/arxiv-import.log','/usr/bin/env','PYTHONDONTWRITEBYTECODE=1',sys.executable,'-B','-m','paper_atlas','import','arxiv','--db',db,'--archive',archive],check=True) if property_value('ActiveState')!='active': raise RuntimeError('arXiv unit did not resume; inspect the logs') print('ARXIV_RESUMED',flush=True) @@ -78,14 +79,18 @@ def merge(db,archive,files,backfill_queue=None): return merge_locked(db,archive,files,backfill_queue) -if __name__=='__main__': - parser=argparse.ArgumentParser() +def main(argv=None, *, prog=None): + parser=argparse.ArgumentParser(prog=prog) parser.add_argument('--db',required=True) parser.add_argument('--archive',required=True) parser.add_argument('--schema-only',action='store_true',help='Apply idempotent schema/index updates at a saved checkpoint') parser.add_argument('--backfill-queue',help='Audited existing-source queue for canonical submission evidence') parser.add_argument('files',nargs='*') - args=parser.parse_args() + args=parser.parse_args(argv) if not args.files and not args.schema_only and not args.backfill_queue: parser.error('Provide collected files or --schema-only') merge(args.db,args.archive,args.files,args.backfill_queue) + + +if __name__ == '__main__': + main() diff --git a/import_records.py b/paper_atlas/ingestion/records.py similarity index 95% rename from import_records.py rename to paper_atlas/ingestion/records.py index fd5c61d..a06545d 100644 --- a/import_records.py +++ b/paper_atlas/ingestion/records.py @@ -4,8 +4,8 @@ import json import time from pathlib import Path -from index_core import Store -from submission_evidence import identities_for_row,timestamp +from paper_atlas.db.store import Store +from paper_atlas.db.submissions import identities_for_row,timestamp def ingest_file(db,path): @@ -84,10 +84,14 @@ def ingest_file(db,path): c.close() -if __name__=='__main__': - parser = argparse.ArgumentParser() +def main(argv=None, *, prog=None): + parser = argparse.ArgumentParser(prog=prog) parser.add_argument('--db',required=True) parser.add_argument('files',nargs='+') - args = parser.parse_args() + args = parser.parse_args(argv) for filename in args.files: ingest_file(args.db,filename) + + +if __name__ == '__main__': + main() diff --git a/paper_atlas/io/__init__.py b/paper_atlas/io/__init__.py new file mode 100644 index 0000000..0c9348d --- /dev/null +++ b/paper_atlas/io/__init__.py @@ -0,0 +1 @@ +"""Io.""" diff --git a/metadata_checkout.py b/paper_atlas/io/git_metadata.py similarity index 100% rename from metadata_checkout.py rename to paper_atlas/io/git_metadata.py diff --git a/paper_atlas/io/html.py b/paper_atlas/io/html.py new file mode 100644 index 0000000..91cd738 --- /dev/null +++ b/paper_atlas/io/html.py @@ -0,0 +1,52 @@ +"""Small HTML tree used by official metadata adapters.""" +from html.parser import HTMLParser + + +class Node: + def __init__(self,tag='',attrs=None): + self.tag=tag + self.attrs={key:value or '' for key,value in attrs or []} + self.children=[] + + def text(self): + if self.tag in ('script','style'): + return '' + content=''.join(child.text() if isinstance(child,Node) else child for child in self.children) + return ' '+content+' ' if self.tag in ('p','div','section','li','br') else content + + def walk(self): + yield self + for child in self.children: + if isinstance(child,Node): + yield from child.walk() + + +class Document(HTMLParser): + VOID={'area','base','br','col','embed','hr','img','input','link','meta','param','source','track','wbr'} + + def __init__(self,html): + super().__init__(convert_charrefs=True) + self.root=Node() + self.stack=[self.root] + self.feed(html) + + def handle_starttag(self,tag,attrs): + node=Node(tag,attrs) + self.stack[-1].children.append(node) + if tag not in self.VOID: + self.stack.append(node) + + def handle_endtag(self,tag): + for index in range(len(self.stack)-1,0,-1): + if self.stack[index].tag==tag: + del self.stack[index:] + break + + def handle_data(self,data): + self.stack[-1].children.append(data) + + +def clean(text): + return ' '.join(text.split()) + + diff --git a/paper_atlas/io/http.py b/paper_atlas/io/http.py new file mode 100644 index 0000000..8d7fd12 --- /dev/null +++ b/paper_atlas/io/http.py @@ -0,0 +1,52 @@ +"""Bounded public metadata requests and atomic response caching.""" +import hashlib +from http.client import IncompleteRead +import json +from pathlib import Path +import ssl +import time +import urllib.error +import urllib.request + + +def fetch(url,cache,limit=64*1024**2,delay=0.5): + cache = Path(cache) + cache.mkdir(parents=True,exist_ok=True) + path = cache/hashlib.sha256(url.encode()).hexdigest() + if path.exists() and time.time()-path.stat().st_mtime < 24*3600: + if path.stat().st_size>limit:raise RuntimeError('Metadata response exceeds byte limit') + return path.read_bytes() + for attempt in range(3): + try: + request = urllib.request.Request(url,headers={'User-Agent':'PaperAtlas/0.1 (public scholarly metadata research)'}) + with urllib.request.urlopen(request,timeout=90) as response: + if response.status != 200: + raise RuntimeError('Expected complete HTTP 200 response') + expected=int(response.headers.get('Content-Length','0')) + if expected>limit: + raise RuntimeError('Metadata response exceeds byte limit') + data = response.read(limit+1) + if len(data)>limit: + raise RuntimeError('Metadata response exceeds byte limit') + if expected and len(data)!=expected: + raise IncompleteRead(data,max(0,expected-len(data))) + part = path.with_suffix('.part') + part.write_bytes(data) + part.replace(path) + path.with_suffix('.json').write_text(json.dumps({'url':url,'sha256':hashlib.sha256(data).hexdigest(),'bytes':len(data),'retrieved_at':time.strftime('%Y-%m-%dT%H:%M:%SZ',time.gmtime())})) + time.sleep(delay) + return data + except urllib.error.HTTPError as exc: + if exc.code not in (429,500,502,503,504) or attempt==2: + exc.close() + raise + retry_after=exc.headers.get('Retry-After','1');exc.close() + try:retry_after=int(retry_after) + except ValueError:retry_after=5 + time.sleep(min(30,max(2**attempt,retry_after))) + except (urllib.error.URLError,TimeoutError,ConnectionError,IncompleteRead,ssl.SSLError) as exc: + reason=getattr(exc,'reason',exc) + if isinstance(reason,ssl.SSLCertVerificationError) or attempt==2:raise + time.sleep(min(30,2**(attempt+1))) + + diff --git a/download_snapshot.py b/paper_atlas/io/snapshots.py similarity index 96% rename from download_snapshot.py rename to paper_atlas/io/snapshots.py index 4f40848..ea7aef3 100644 --- a/download_snapshot.py +++ b/paper_atlas/io/snapshots.py @@ -98,9 +98,13 @@ def download(url,output,sha256=None,md5=None,expected_bytes=None,resume=False,mi return manifest -if __name__=='__main__': - parser=argparse.ArgumentParser();parser.add_argument('--url',required=True);parser.add_argument('--output',required=True) +def main(argv=None, *, prog=None): + parser=argparse.ArgumentParser(prog=prog);parser.add_argument('--url',required=True);parser.add_argument('--output',required=True) parser.add_argument('--sha256');parser.add_argument('--md5');parser.add_argument('--expected-bytes',type=int) parser.add_argument('--resume',action='store_true');parser.add_argument('--min-free-gib',type=float,default=8) - args=parser.parse_args() + args=parser.parse_args(argv) print(json.dumps(download(args.url,args.output,args.sha256,args.md5,args.expected_bytes,args.resume,int(args.min_free_gib*1024**3))),flush=True) + + +if __name__ == '__main__': + main() diff --git a/query.py b/paper_atlas/querying.py similarity index 98% rename from query.py rename to paper_atlas/querying.py index 04354fb..1f7f344 100644 --- a/query.py +++ b/paper_atlas/querying.py @@ -5,8 +5,8 @@ import sqlite3 from pathlib import Path -def main(): - parser = argparse.ArgumentParser() +def main(argv=None, *, prog=None): + parser = argparse.ArgumentParser(prog=prog) parser.add_argument('--db', required=True) parser.add_argument('--query', help='SQLite FTS5 query over title and abstract') parser.add_argument('--paper',type=int,help='Paper ID; include sources, tags and submission history') @@ -17,7 +17,7 @@ def main(): parser.add_argument('--status', choices=['accepted','rejected','desk_rejected','withdrawn','under_review','unknown']) parser.add_argument('--limit', type=int, default=20) parser.add_argument('--stats', action='store_true') - args = parser.parse_args() + args = parser.parse_args(argv) conn = sqlite3.connect(Path(args.db).resolve().as_uri()+'?mode=ro', uri=True) conn.row_factory = sqlite3.Row conn.execute('BEGIN') # One consistent read snapshot while ingestion continues. diff --git a/paper_atlas/resources/__init__.py b/paper_atlas/resources/__init__.py new file mode 100644 index 0000000..8edbd48 --- /dev/null +++ b/paper_atlas/resources/__init__.py @@ -0,0 +1 @@ +"""Resources.""" diff --git a/venues.json b/paper_atlas/resources/venues.json similarity index 100% rename from venues.json rename to paper_atlas/resources/venues.json diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..fdfb5af --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,22 @@ +[build-system] +requires = ["setuptools>=77"] +build-backend = "setuptools.build_meta" + +[project] +name = "paper-atlas" +version = "0.1.0" +description = "Long-term, on-demand paper metadata and public submission history index" +readme = "README.md" +requires-python = ">=3.12" +dependencies = ["PyYAML==6.0.3"] + +[project.scripts] +paper-atlas = "paper_atlas.cli:main" + +[tool.setuptools.packages.find] +include = ["paper_atlas*"] +exclude = ["tests*"] + +[tool.setuptools.package-data] +"paper_atlas.db" = ["schema.sql"] +"paper_atlas.resources" = ["venues.json"] diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index f62ce0c..0000000 --- a/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -PyYAML==6.0.3 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..d420712 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Tests.""" diff --git a/test_acl.py b/tests/test_acl.py similarity index 96% rename from test_acl.py rename to tests/test_acl.py index 99775ba..41fd9a9 100644 --- a/test_acl.py +++ b/tests/test_acl.py @@ -4,7 +4,7 @@ import unittest import xml.etree.ElementTree as ET from pathlib import Path -from collect_acl import paper_id,volume_scope,collect_xml +from paper_atlas.collectors.acl import paper_id,volume_scope,collect_xml class AnthologyTests(unittest.TestCase): diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..5b9ba26 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,32 @@ +import json +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest + +from paper_atlas.cli import COMMANDS + + +class CliTests(unittest.TestCase): + def test_every_registered_command_has_help_without_starting_work(self): + for route in COMMANDS: + with self.subTest(route=route): + result=subprocess.run([sys.executable,'-B','-m','paper_atlas',*route,'--help'],capture_output=True,text=True) + self.assertEqual(result.returncode,0,result.stderr) + self.assertIn('paper-atlas '+' '.join(route),result.stdout) + + def test_unified_import_and_query_use_shipped_schema_and_registry(self): + from paper_atlas.config import DEFAULT_REGISTRY + self.assertEqual(len(json.loads(DEFAULT_REGISTRY.read_text())['venues']),41) + with tempfile.TemporaryDirectory() as tmp: + root=Path(tmp);db=root/'db.sqlite';source=root/'records.jsonl' + source.write_text(json.dumps({'source':'example','external_id':'one','url':'https://example.org','title':'CLI paper','abstract':'Abstract','authors':['Jane Doe'],'venue':'Example','year':2026,'track':'main','status':'accepted'})+'\n') + source.with_suffix('.manifest.json').write_text(json.dumps({'source':'example','list_url':'https://example.org','count':1})) + for args in [('import','records','--db',str(db),str(source)),('query','--db',str(db),'--stats')]: + result=subprocess.run([sys.executable,'-B','-m','paper_atlas',*args],capture_output=True,text=True) + self.assertEqual(result.returncode,0,result.stderr) + self.assertEqual(json.loads(result.stdout)['papers'],1) + + +if __name__=='__main__':unittest.main() diff --git a/test_coverage_report.py b/tests/test_coverage_report.py similarity index 96% rename from test_coverage_report.py rename to tests/test_coverage_report.py index c2c4e94..d56364a 100644 --- a/test_coverage_report.py +++ b/tests/test_coverage_report.py @@ -3,8 +3,8 @@ from pathlib import Path import tempfile import unittest -from coverage_report import report -from index_core import Store +from paper_atlas.audits.coverage import report +from paper_atlas.db.store import Store class CoverageTests(unittest.TestCase): diff --git a/test_dblp_catalogue.py b/tests/test_dblp_catalogue.py similarity index 98% rename from test_dblp_catalogue.py rename to tests/test_dblp_catalogue.py index 67bc23a..bf350a1 100644 --- a/test_dblp_catalogue.py +++ b/tests/test_dblp_catalogue.py @@ -6,7 +6,7 @@ import sqlite3 import tempfile import unittest -from catalogue_dblp import catalogue,records +from paper_atlas.collectors.dblp import catalogue,records class DblpCatalogueTests(unittest.TestCase): diff --git a/test_download_snapshot.py b/tests/test_download_snapshot.py similarity index 79% rename from test_download_snapshot.py rename to tests/test_download_snapshot.py index 301c7aa..28945aa 100644 --- a/test_download_snapshot.py +++ b/tests/test_download_snapshot.py @@ -6,7 +6,7 @@ import tempfile import unittest from unittest.mock import patch -from download_snapshot import download +from paper_atlas.io.snapshots import download class Response(io.BytesIO): @@ -19,16 +19,16 @@ class DownloadTests(unittest.TestCase): body=b'public metadata';sha=hashlib.sha256(body).hexdigest() with tempfile.TemporaryDirectory() as tmp: path=Path(tmp)/'snapshot.gz' - with patch('download_snapshot.urllib.request.urlopen',return_value=Response(body)): + with patch('paper_atlas.io.snapshots.urllib.request.urlopen',return_value=Response(body)): result=download('https://example.org/snapshot.gz',path,sha256=sha,expected_bytes=len(body),min_free=0) self.assertEqual(result['status'],'verified') - with patch('download_snapshot.urllib.request.urlopen',side_effect=AssertionError('No network expected')): + with patch('paper_atlas.io.snapshots.urllib.request.urlopen',side_effect=AssertionError('No network expected')): reused=download('https://example.org/snapshot.gz',path,sha256=sha,min_free=0) self.assertEqual(reused['status'],'already_verified') self.assertEqual(json.loads(Path(str(path)+'.manifest.json').read_text())['sha256'],sha) def test_bad_checksum_never_publishes_a_snapshot(self): - with tempfile.TemporaryDirectory() as tmp,patch('download_snapshot.urllib.request.urlopen',return_value=Response(b'wrong bytes')): + with tempfile.TemporaryDirectory() as tmp,patch('paper_atlas.io.snapshots.urllib.request.urlopen',return_value=Response(b'wrong bytes')): path=Path(tmp)/'snapshot.gz' with self.assertRaisesRegex(ValueError,'SHA256 mismatch'): download('https://example.org/snapshot.gz',path,sha256='0'*64,min_free=0) @@ -41,16 +41,16 @@ class DownloadTests(unittest.TestCase): Path(str(part)+'.json').write_text(json.dumps({'url':url,'expected_sha256':sha,'expected_md5':None,'expected_bytes':6})) with self.assertRaisesRegex(ValueError,'same snapshot'): download(url,path,sha256=sha,expected_bytes=6,min_free=0) - with patch('download_snapshot.urllib.request.urlopen',return_value=Response(body)): + with patch('paper_atlas.io.snapshots.urllib.request.urlopen',return_value=Response(body)): with self.assertRaisesRegex(ValueError,'resume range'): download(url,path,sha256=sha,expected_bytes=6,resume=True,min_free=0) self.assertEqual(part.read_bytes(),b'abc') - with patch('download_snapshot.urllib.request.urlopen',return_value=Response(b'def',206,{'Content-Range':'bytes 3-5/6'})): + with patch('paper_atlas.io.snapshots.urllib.request.urlopen',return_value=Response(b'def',206,{'Content-Range':'bytes 3-5/6'})): download(url,path,sha256=sha,expected_bytes=6,resume=True,min_free=0) self.assertEqual(path.read_bytes(),body) def test_wrong_http_length_is_rejected_before_writing(self): - with tempfile.TemporaryDirectory() as tmp,patch('download_snapshot.urllib.request.urlopen',return_value=Response(b'abc')): + with tempfile.TemporaryDirectory() as tmp,patch('paper_atlas.io.snapshots.urllib.request.urlopen',return_value=Response(b'abc')): path=Path(tmp)/'snapshot.gz' with self.assertRaisesRegex(ValueError,'HTTP size'): download('https://example.org/snapshot.gz',path,sha256='0'*64,expected_bytes=4,min_free=0) @@ -61,7 +61,7 @@ class DownloadTests(unittest.TestCase): with tempfile.TemporaryDirectory() as tmp: path=Path(tmp)/'snapshot.gz';part=Path(str(path)+'.part');part.write_bytes(b'') Path(str(part)+'.json').write_text(json.dumps({'url':url,'expected_sha256':sha,'expected_md5':None,'expected_bytes':3})) - with patch('download_snapshot.urllib.request.urlopen',return_value=Response(body)): + with patch('paper_atlas.io.snapshots.urllib.request.urlopen',return_value=Response(body)): download(url,path,sha256=sha,expected_bytes=3,resume=True,min_free=0) metadata=Path(str(path)+'.manifest.json');metadata.unlink() download(url,path,sha256=sha,expected_bytes=3,min_free=0) diff --git a/test_fetch.py b/tests/test_fetch.py similarity index 66% rename from test_fetch.py rename to tests/test_fetch.py index 720bf4b..0a49f9d 100644 --- a/test_fetch.py +++ b/tests/test_fetch.py @@ -6,7 +6,7 @@ import unittest from unittest.mock import patch from urllib.error import HTTPError,URLError -from collect_pmlr import fetch +from paper_atlas.io.http import fetch class Response(io.BytesIO): @@ -16,30 +16,30 @@ class Response(io.BytesIO): class FetchTests(unittest.TestCase): def test_transient_connection_failure_is_retried(self): - with tempfile.TemporaryDirectory() as tmp,patch('collect_pmlr.time.sleep'),patch('collect_pmlr.urllib.request.urlopen',side_effect=[URLError(ConnectionResetError('reset')),Response(b'complete')]) as network: + with tempfile.TemporaryDirectory() as tmp,patch('paper_atlas.io.http.time.sleep'),patch('paper_atlas.io.http.urllib.request.urlopen',side_effect=[URLError(ConnectionResetError('reset')),Response(b'complete')]) as network: self.assertEqual(fetch('https://example.org/metadata',tmp),b'complete') self.assertEqual(network.call_count,2) def test_truncated_response_is_not_cached_as_complete(self): - with tempfile.TemporaryDirectory() as tmp,patch('collect_pmlr.time.sleep'),patch('collect_pmlr.urllib.request.urlopen',side_effect=[Response(b'bad',expected=8),Response(b'complete')]): + with tempfile.TemporaryDirectory() as tmp,patch('paper_atlas.io.http.time.sleep'),patch('paper_atlas.io.http.urllib.request.urlopen',side_effect=[Response(b'bad',expected=8),Response(b'complete')]): self.assertEqual(fetch('https://example.org/metadata',tmp),b'complete') path=Path(tmp)/hashlib.sha256(b'https://example.org/metadata').hexdigest() self.assertEqual(path.read_bytes(),b'complete') def test_forbidden_response_is_not_retried_or_bypassed(self): body=io.BytesIO(b'Forbidden');error=HTTPError('https://example.org/metadata',403,'Forbidden',{},body) - with tempfile.TemporaryDirectory() as tmp,patch('collect_pmlr.time.sleep'),patch('collect_pmlr.urllib.request.urlopen',side_effect=error) as network: + with tempfile.TemporaryDirectory() as tmp,patch('paper_atlas.io.http.time.sleep'),patch('paper_atlas.io.http.urllib.request.urlopen',side_effect=error) as network: with self.assertRaises(HTTPError):fetch('https://example.org/metadata',tmp) self.assertEqual(network.call_count,1) self.assertTrue(body.closed) def test_transport_retries_are_bounded(self): - with tempfile.TemporaryDirectory() as tmp,patch('collect_pmlr.time.sleep'),patch('collect_pmlr.urllib.request.urlopen',side_effect=ConnectionResetError('reset')) as network: + with tempfile.TemporaryDirectory() as tmp,patch('paper_atlas.io.http.time.sleep'),patch('paper_atlas.io.http.urllib.request.urlopen',side_effect=ConnectionResetError('reset')) as network: with self.assertRaises(ConnectionResetError):fetch('https://example.org/metadata',tmp) self.assertEqual(network.call_count,3) def test_cached_response_still_obeys_the_size_limit(self): - with tempfile.TemporaryDirectory() as tmp,patch('collect_pmlr.urllib.request.urlopen') as network: + with tempfile.TemporaryDirectory() as tmp,patch('paper_atlas.io.http.urllib.request.urlopen') as network: path=Path(tmp)/hashlib.sha256(b'https://example.org/metadata').hexdigest();path.write_bytes(b'too large') with self.assertRaisesRegex(RuntimeError,'exceeds byte limit'): fetch('https://example.org/metadata',tmp,limit=2) diff --git a/test_iclr.py b/tests/test_iclr.py similarity index 95% rename from test_iclr.py rename to tests/test_iclr.py index fb9b983..dd595ff 100644 --- a/test_iclr.py +++ b/tests/test_iclr.py @@ -5,9 +5,9 @@ import unittest from unittest.mock import patch from urllib.error import HTTPError -from collect_iclr import asset_urls, collect_year, discover, parse_program -from import_records import ingest_file -from index_core import Store +from paper_atlas.collectors.iclr import asset_urls, collect_year, discover, parse_program +from paper_atlas.ingestion.records import ingest_file +from paper_atlas.db.store import Store class ICLRTests(unittest.TestCase): @@ -75,7 +75,7 @@ class ICLRTests(unittest.TestCase): html=b'start("/static/virtual/data/iclr-2021-orals-posters.json",true,"/static/virtual/data/iclr-2021-abstracts.json")' event=self.event(sourceurl=None,decision='Poster',abstract='Embedded historical abstract') responses=[html,json.dumps({'count':1,'results':[event]}).encode(),HTTPError('https://iclr.cc/abstracts',404,'Not Found',None,None)] - with tempfile.TemporaryDirectory() as tmp,patch('collect_iclr.fetch',side_effect=responses): + with tempfile.TemporaryDirectory() as tmp,patch('paper_atlas.collectors.iclr.fetch',side_effect=responses): manifest=collect_year(2021,tmp,tmp) self.assertEqual(manifest['status'],'collected') self.assertEqual(manifest['missing_abstract_count'],0) diff --git a/test_iclr_snapshot_audit.py b/tests/test_iclr_snapshot_audit.py similarity index 98% rename from test_iclr_snapshot_audit.py rename to tests/test_iclr_snapshot_audit.py index 71d0a05..01d00fa 100644 --- a/test_iclr_snapshot_audit.py +++ b/tests/test_iclr_snapshot_audit.py @@ -3,7 +3,7 @@ from pathlib import Path import tempfile import unittest -from audit_iclr_snapshot import audit +from paper_atlas.audits.iclr import audit class SnapshotAuditTests(unittest.TestCase): diff --git a/test_import_arxiv.py b/tests/test_import_arxiv.py similarity index 98% rename from test_import_arxiv.py rename to tests/test_import_arxiv.py index d973cd4..e6b6c12 100644 --- a/test_import_arxiv.py +++ b/tests/test_import_arxiv.py @@ -6,7 +6,7 @@ import unittest import zipfile from pathlib import Path -from import_arxiv import ingest +from paper_atlas.ingestion.arxiv import ingest class ImportTests(unittest.TestCase): diff --git a/test_index_core.py b/tests/test_index_core.py similarity index 98% rename from test_index_core.py rename to tests/test_index_core.py index c55c331..edb2441 100644 --- a/test_index_core.py +++ b/tests/test_index_core.py @@ -4,8 +4,8 @@ import tempfile import unittest from pathlib import Path -from index_core import Store -from import_records import ingest_file +from paper_atlas.db.store import Store +from paper_atlas.ingestion.records import ingest_file class StoreTests(unittest.TestCase): diff --git a/test_maintenance.py b/tests/test_maintenance.py similarity index 72% rename from test_maintenance.py rename to tests/test_maintenance.py index 057f9d2..b46d07e 100644 --- a/test_maintenance.py +++ b/tests/test_maintenance.py @@ -3,8 +3,8 @@ import tempfile import unittest from unittest.mock import patch -from index_core import Store -from merge_conferences import merge +from paper_atlas.db.store import Store +from paper_atlas.ingestion.maintenance import merge class MaintenanceTests(unittest.TestCase): @@ -17,14 +17,14 @@ class MaintenanceTests(unittest.TestCase): def test_completed_archive_is_not_started_again_after_maintenance(self): with tempfile.TemporaryDirectory() as tmp: db,archive=self.fixture(tmp,'complete') - with patch('merge_conferences.property_value',return_value='inactive'),patch('merge_conferences.subprocess.run') as run: + with patch('paper_atlas.ingestion.maintenance.property_value',return_value='inactive'),patch('paper_atlas.ingestion.maintenance.subprocess.run') as run: merge(db,archive,[]) run.assert_not_called() def test_inactive_incomplete_arxiv_requires_investigation(self): with tempfile.TemporaryDirectory() as tmp: db,archive=self.fixture(tmp,'failed') - with patch('merge_conferences.property_value',return_value='inactive'),patch('merge_conferences.subprocess.run') as run: + with patch('paper_atlas.ingestion.maintenance.property_value',return_value='inactive'),patch('paper_atlas.ingestion.maintenance.subprocess.run') as run: with self.assertRaisesRegex(RuntimeError,'neither active nor verified complete'): merge(db,archive,[]) run.assert_not_called() @@ -32,7 +32,7 @@ class MaintenanceTests(unittest.TestCase): def test_arxiv_finishing_at_the_stop_boundary_is_not_restarted(self): with tempfile.TemporaryDirectory() as tmp: db,archive=self.fixture(tmp,'complete') - with patch('merge_conferences.property_value',return_value='active'),patch('merge_conferences.subprocess.run') as run: + with patch('paper_atlas.ingestion.maintenance.property_value',return_value='active'),patch('paper_atlas.ingestion.maintenance.subprocess.run') as run: merge(db,archive,[]) self.assertEqual(run.call_args_list[0].args[0],['systemctl','stop','paper-atlas-arxiv-import.service']) self.assertEqual(run.call_count,1) diff --git a/test_openreview_snapshot.py b/tests/test_openreview_snapshot.py similarity index 97% rename from test_openreview_snapshot.py rename to tests/test_openreview_snapshot.py index 46e1640..0139fd9 100644 --- a/test_openreview_snapshot.py +++ b/tests/test_openreview_snapshot.py @@ -7,8 +7,8 @@ import tarfile import tempfile import unittest -from collect_openreview_snapshot import collect,decision_record,paper_record -from import_records import ingest_file +from paper_atlas.collectors.openreview import collect,decision_record,paper_record +from paper_atlas.ingestion.records import ingest_file class SnapshotTests(unittest.TestCase): diff --git a/test_peerread.py b/tests/test_peerread.py similarity index 73% rename from test_peerread.py rename to tests/test_peerread.py index 40ac185..d7be011 100644 --- a/test_peerread.py +++ b/tests/test_peerread.py @@ -5,9 +5,9 @@ import unittest from pathlib import Path from unittest.mock import patch -from collect_peerread import collect -from import_records import ingest_file -from index_core import Store +from paper_atlas.collectors.peerread import collect +from paper_atlas.ingestion.records import ingest_file +from paper_atlas.db.store import Store class PeerReadTests(unittest.TestCase): @@ -16,7 +16,7 @@ class PeerReadTests(unittest.TestCase): paper={'conference':'ICLR 2017 conference submission','id':'123','title':'Public paper','abstract':'Abstract','authors':'Jane Doe, John Smith','accepted':False,'reviews':[{'text':'Do not persist this review body'}]} with tempfile.TemporaryDirectory() as tmp: root=Path(tmp) - with patch('collect_peerread.fetch',side_effect=[json.dumps({'sha':'a'*40}).encode(),json.dumps(tree).encode()]),patch('collect_peerread.urllib.request.urlopen',return_value=io.BytesIO(json.dumps(paper).encode())),patch('collect_peerread.time.sleep'): + with patch('paper_atlas.collectors.peerread.fetch',side_effect=[json.dumps({'sha':'a'*40}).encode(),json.dumps(tree).encode()]),patch('paper_atlas.collectors.peerread.urllib.request.urlopen',return_value=io.BytesIO(json.dumps(paper).encode())),patch('paper_atlas.collectors.peerread.time.sleep'): collect(root/'cache',root/'out') path=root/'out/peerread-iclr2017-main.jsonl' record=json.loads(path.read_text()) diff --git a/test_pmlr.py b/tests/test_pmlr.py similarity index 78% rename from test_pmlr.py rename to tests/test_pmlr.py index 0af571d..e2da93e 100644 --- a/test_pmlr.py +++ b/tests/test_pmlr.py @@ -4,7 +4,7 @@ import json from pathlib import Path from unittest.mock import patch -from collect_pmlr import load_metadata,venue_key,collect_volume +from paper_atlas.collectors.pmlr import load_metadata,venue_key,collect_volume class MetadataTests(unittest.TestCase): @@ -27,7 +27,7 @@ class MetadataTests(unittest.TestCase): def test_missing_abstract_is_reported_as_partial_collection(self): config=b'shortname: ICML\nyear: 2025\nbooktitle: ICML\n' paper='---\nid: paper1\nlayout: inproceedings\ntitle: Paper\nabstract:\n---\n' - with tempfile.TemporaryDirectory() as tmp,patch('collect_pmlr.fetch',return_value=config),patch('collect_pmlr.metadata_texts',return_value=iter([('paper.md',paper)])): + with tempfile.TemporaryDirectory() as tmp,patch('paper_atlas.collectors.pmlr.fetch',return_value=config),patch('paper_atlas.collectors.pmlr.metadata_texts',return_value=iter([('paper.md',paper)])): result=collect_volume(999,tmp,tmp) self.assertEqual(result['count'],1) self.assertEqual(result['missing_abstract_count'],1) @@ -37,7 +37,7 @@ class MetadataTests(unittest.TestCase): config=b'shortname: CoRL\nyear: 2023\nbooktitle: CoRL\n' paper='---\nid: paper1\nlayout: inproceedings\ntitle: Paper\nabstract: Real abstract\ndate: {date}\n---\n' posts=[('2023-11-13-paper1.md',paper.format(date='2023-11-13')),('2023-12-02-paper1.md',paper.format(date='2023-12-02'))] - with tempfile.TemporaryDirectory() as tmp,patch('collect_pmlr.fetch',return_value=config),patch('collect_pmlr.metadata_texts',return_value=iter(posts)): + with tempfile.TemporaryDirectory() as tmp,patch('paper_atlas.collectors.pmlr.fetch',return_value=config),patch('paper_atlas.collectors.pmlr.metadata_texts',return_value=iter(posts)): result=collect_volume(229,tmp,tmp) rows=[json.loads(line) for line in Path(result['file']).read_text().splitlines()] self.assertEqual(result['count'],1) @@ -49,7 +49,7 @@ class MetadataTests(unittest.TestCase): def test_conflicting_duplicate_posts_do_not_replace_previous_collection(self): config=b'shortname: CoRL\nyear: 2023\nbooktitle: CoRL\n' paper='---\nid: paper1\nlayout: inproceedings\ntitle: {title}\nabstract: Abstract\n---\n' - with tempfile.TemporaryDirectory() as tmp,patch('collect_pmlr.fetch',return_value=config),patch('collect_pmlr.metadata_texts',return_value=iter([('a.md',paper.format(title='First paper')),('b.md',paper.format(title='Other paper'))])): + with tempfile.TemporaryDirectory() as tmp,patch('paper_atlas.collectors.pmlr.fetch',return_value=config),patch('paper_atlas.collectors.pmlr.metadata_texts',return_value=iter([('a.md',paper.format(title='First paper')),('b.md',paper.format(title='Other paper'))])): previous=Path(tmp)/'pmlr-v229.jsonl';previous.write_text('previous valid collection') with self.assertRaisesRegex(ValueError,'Conflicting metadata posts'): collect_volume(229,tmp,tmp) diff --git a/test_proceedings.py b/tests/test_proceedings.py similarity index 90% rename from test_proceedings.py rename to tests/test_proceedings.py index 4c9f607..2e8bad1 100644 --- a/test_proceedings.py +++ b/tests/test_proceedings.py @@ -1,11 +1,10 @@ import unittest import json -import runpy import tempfile from pathlib import Path from unittest.mock import patch -from collect_proceedings import paper_links,parse_paper +from paper_atlas.collectors.proceedings import paper_links,parse_paper,main class ProceedingsTests(unittest.TestCase): @@ -50,9 +49,9 @@ class ProceedingsTests(unittest.TestCase): with tempfile.TemporaryDirectory() as tmp: status=Path(tmp)/'colm-index-status.json' status.write_text(json.dumps({other:{'status':'collected','files':[]}})) - argv=['collect_proceedings.py','--source','colm','--cache',tmp,'--out',tmp,'--year','2024'] - with patch('sys.argv',argv),patch('collect_pmlr.fetch',side_effect=lambda u,*a,**kw:documents[u].encode()): - runpy.run_path(str(Path(__file__).with_name('collect_proceedings.py')),run_name='__main__') + argv=['paper-atlas','--source','colm','--cache',tmp,'--out',tmp,'--year','2024'] + with patch('sys.argv',argv),patch('paper_atlas.collectors.proceedings.fetch',side_effect=lambda u,*a,**kw:documents[u].encode()): + main() result=json.loads(status.read_text()) self.assertEqual(result[other]['status'],'collected') self.assertEqual(result[url]['files'][0]['count'],1) diff --git a/test_query.py b/tests/test_query.py similarity index 82% rename from test_query.py rename to tests/test_query.py index 068d4b9..e3497f4 100644 --- a/test_query.py +++ b/tests/test_query.py @@ -5,7 +5,7 @@ import tempfile import unittest from pathlib import Path -from index_core import Store +from paper_atlas.db.store import Store class QueryTests(unittest.TestCase): @@ -16,7 +16,7 @@ class QueryTests(unittest.TestCase): store.decision(paper,record,'COLING+LREC',2024,'main','paper1','https://example.org/1','accepted','official XML',['COLING','LREC']) store.conn.commit() for venue in ('COLING','LREC'): - command=[sys.executable,'-B',str(Path(__file__).with_name('query.py')),'--db',str(db),'--venue',venue,'--track','main'] + command=[sys.executable,'-B','-m','paper_atlas','query','--db',str(db),'--venue',venue,'--track','main'] rows=json.loads(subprocess.run(command,check=True,capture_output=True,text=True).stdout) self.assertEqual([r['id'] for r in rows],[paper]) self.assertEqual(store.conn.execute('SELECT count(*) FROM submissions').fetchone()[0],1) @@ -30,15 +30,15 @@ class QueryTests(unittest.TestCase): store.decision(paper,record,'ICLR',2017,'main','paper1','https://example.org/paper1','rejected','explicit rejection') store.record('arxiv','2601.00001','https://example.org/other','Another paper','Abstract',['John Smith']) store.conn.commit() - command=[sys.executable,'-B',str(Path(__file__).with_name('query.py')),'--db',str(db),'--venue','iclr','--year','2017','--status','rejected'] + command=[sys.executable,'-B','-m','paper_atlas','query','--db',str(db),'--venue','iclr','--year','2017','--status','rejected'] result=json.loads(subprocess.run(command,check=True,capture_output=True,text=True).stdout) self.assertEqual([r['id'] for r in result],[paper]) - details_command=[sys.executable,'-B',str(Path(__file__).with_name('query.py')),'--db',str(db),'--paper',str(paper)] + details_command=[sys.executable,'-B','-m','paper_atlas','query','--db',str(db),'--paper',str(paper)] details=json.loads(subprocess.run(details_command,check=True,capture_output=True,text=True).stdout)[0] self.assertEqual(details['authors'],['Jane Doe']) self.assertEqual(details['submissions'][0]['decisions'][0]['status'],'rejected') self.assertEqual(details['sources'][0]['external_id'],'paper1') - stats_command=[sys.executable,'-B',str(Path(__file__).with_name('query.py')),'--db',str(db),'--stats'] + stats_command=[sys.executable,'-B','-m','paper_atlas','query','--db',str(db),'--stats'] stats=json.loads(subprocess.run(stats_command,check=True,capture_output=True,text=True).stdout) self.assertEqual(stats['papers'],2) self.assertEqual({r['name']:r['imported_records'] for r in stats['records_by_source']},{'arxiv':1,'peerread':1}) diff --git a/test_submission_evidence.py b/tests/test_submission_evidence.py similarity index 96% rename from test_submission_evidence.py rename to tests/test_submission_evidence.py index 71e195c..aa9d067 100644 --- a/test_submission_evidence.py +++ b/tests/test_submission_evidence.py @@ -1,3 +1,4 @@ +from importlib.resources import files import json from pathlib import Path import sqlite3 @@ -6,10 +7,10 @@ import sys import tempfile import unittest -from import_records import ingest_file -from index_core import Store -from submission_evidence import identities_for_row -from backfill_submission_evidence import backfill,queue_files +from paper_atlas.ingestion.records import ingest_file +from paper_atlas.db.store import Store +from paper_atlas.db.submissions import identities_for_row +from paper_atlas.ingestion.backfill import backfill,queue_files class SubmissionTests(unittest.TestCase): @@ -25,7 +26,7 @@ class SubmissionTests(unittest.TestCase): def query(self,*args): self.store.conn.commit() - command=[sys.executable,'-B',str(Path(__file__).with_name('query.py')),'--db',str(self.db),*args] + command=[sys.executable,'-B','-m','paper_atlas','query','--db',str(self.db),*args] result=subprocess.run(command,capture_output=True,text=True) self.assertEqual(result.returncode,0,result.stderr) return json.loads(result.stdout) @@ -125,7 +126,7 @@ class SubmissionTests(unittest.TestCase): identities_for_row({'source':'snapshot','venue':'ICLR','submission_identifiers':[{'scheme':'openreview','value':'2026-Oral--42-placeholder'}]}) def test_legacy_database_migration_preserves_source_and_decision_links(self): - legacy=Path(self.tmp.name)/'legacy.sqlite';sql=Path(__file__).with_name('schema.sql').read_text() + legacy=Path(self.tmp.name)/'legacy.sqlite';sql=files('paper_atlas.db').joinpath('schema.sql').read_text() sql=sql[:sql.index('CREATE TABLE IF NOT EXISTS submission_sources')]+sql[sql.index('CREATE TABLE IF NOT EXISTS tags'):] c=sqlite3.connect(legacy);c.executescript(sql) c.execute("INSERT INTO sources VALUES(1,'legacy','https://example.org')") diff --git a/test_vision.py b/tests/test_vision.py similarity index 93% rename from test_vision.py rename to tests/test_vision.py index e236bc4..a93e31f 100644 --- a/test_vision.py +++ b/tests/test_vision.py @@ -2,7 +2,7 @@ import tempfile import unittest from unittest.mock import patch -from collect_vision import collect_index, decode_html, discover, index_entries, parse_paper +from paper_atlas.collectors.vision import collect_index, decode_html, discover, index_entries, parse_paper class VisionTests(unittest.TestCase): @@ -46,7 +46,7 @@ class VisionTests(unittest.TestCase): second='https://openaccess.thecvf.com/content/CVPR2026/html/Second_CVPR_2026_paper.html' def item(url):return f'
Paper
Jane Doe, John Smith
[pdf]
' pages={root:f'All papers
{item(first)}
',all_url:f'
{item(first)}{item(second)}
',first:'
First paper
First abstract
',second:'
Second paper

Footer without an abstract

'} - with tempfile.TemporaryDirectory() as tmp,patch('collect_vision.fetch',side_effect=lambda url,*a,**kw:pages[url].encode()) as fetch: + with tempfile.TemporaryDirectory() as tmp,patch('paper_atlas.collectors.vision.fetch',side_effect=lambda url,*a,**kw:pages[url].encode()) as fetch: manifest=collect_index('cvf',{'venue':'CVPR','year':2026,'track':'main','index_url':root},tmp,tmp) self.assertEqual(manifest['count'],2) self.assertEqual(manifest['list_url'],all_url) @@ -56,7 +56,7 @@ class VisionTests(unittest.TestCase): def test_discovery_keeps_findings_separate_and_excludes_workshops(self): html='MainFindingsMainLegacy mainWorkshopsWACV' - with patch('collect_vision.fetch',return_value=html.encode()):indexes=discover('cvf','unused') + with patch('paper_atlas.collectors.vision.fetch',return_value=html.encode()):indexes=discover('cvf','unused') self.assertEqual({(x['venue'],x['year'],x['track']) for x in indexes},{('CVPR',2013,'main'),('CVPR',2026,'main'),('CVPR',2026,'findings'),('ICCV',2025,'main')}) def test_unparsed_publication_link_does_not_silently_reduce_coverage(self):