
Paper Select Journal
- 68 installs
- 2.6k repo stars
- Updated July 20, 2026
- huangwb8/chineseresearchlatex
Recommends SCI journals for a manuscript, building a candidate pool from an impact-factor filter then verifying scope, quality, and recent PubMed activity.
About
Takes a manuscript in various formats and produces a ranked Markdown journal-selection report, first hard-filtering candidates via a built-in impact-factor table then verifying scope and recent PubMed papers online. Researchers use it to choose which SCI journals to submit to based on the manuscript and their preferences.
- Built-in 2023IF.xlsx hard filter plus online scope/quality checks
- Outputs a recommendation-ranked Markdown journal report
Paper Select Journal by the numbers
- 68 all-time installs (skills.sh)
- +2 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #1,510 of 3,282 Productivity & Planning skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/huangwb8/chineseresearchlatex --skill paper-select-journalAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 68 |
|---|---|
| repo stars | ★ 2.6k |
| Last updated | July 20, 2026 |
| Repository | huangwb8/chineseresearchlatex ↗ |
What it does
Recommends SCI journals for a manuscript, building a candidate pool from an impact-factor filter then verifying scope, quality, and recent PubMed activity.
Files
Paper Select Journal
核心原则
- 当前信息必须实时核验:scope、官网、业内认可度、中科院分区、近 3 个月论文都属于时效性信息,不能靠旧记忆。
- 中间文件只允许落在当前工作目录下的
.bensz-api/skills/paper-select-journal/隐藏目录;用户若明确指定其他目录,才可覆盖默认值。 - Set1 不再依赖固定语义权重。脚本只负责最小硬过滤与候选池整理,真正的语义规划由当前宿主模型完成。
- Set1 不是最终答案。最终报告只保留证据充分的 Set3,最多 10 个期刊。
- 不能推荐明显预警、垃圾期刊或影响因子低于
3的期刊;若确实保留低于3的例外,必须写明“为何它仍是领域内人类专家认可的稳妥选择”。 - 宁可少报,也不要为了凑满 10 个而硬凑。
输入与工作区
- 用户需求可选,manuscript 必选。
- manuscript 可来自粘贴的标题 / 摘要 / 全文片段,或本地
.md、.txt、.tex、.pdf、.docx,也可混合提供。 - 一旦进入隐藏工作区流程,后续供脚本读取的
analysis/*.json必须保留在当前 run 目录内;不要把manuscript_profile.json、set2_scope_review.json、final_recommendations.json指到 run 目录外。
先初始化隐藏工作区:
python3 <skill_root>/scripts/init_workspace.py --project-root .脚本会创建 .bensz-api/skills/paper-select-journal/{yyyy-mm-dd-hh-mm}/,其中至少包含:
inputs/analysis/candidates/pubmed/reports/
后续所有中间文件都必须留在该 run 目录内。
工作流
1. 先写 manuscript 画像
完整理解论文后,把结果写入 analysis/manuscript_profile.json。
- 模板:
templates/manuscript_profile.template.json - 写法:
references/manuscript-profile.md
最低字段:
titleabstractkeywordsmanuscript_summary
画像的作用是帮助 AI 理解稿件,而不是喂给固定打分公式。 如果用户偏好复杂,优先把偏好写成自然语言放进 target_journal_brief 或 notes,不要为了脚本凑很多硬编码线索。 如果确实需要保留低 IF 的人工例外期刊,只能作为后续人工补录候选,并且必须在最终报告里解释“为什么它虽然低于阈值,仍是领域内稳妥选择”。
2. 用内置 2023IF.xlsx 做 Set1 候选池
内置目录:assets/journal_catalog/2023IF.xlsx
运行:
python3 <skill_root>/scripts/shortlist_journals.py \
--workspace .bensz-api/skills/paper-select-journal/{yyyy-mm-dd-hh-mm} \
--profile .bensz-api/skills/paper-select-journal/{yyyy-mm-dd-hh-mm}/analysis/manuscript_profile.json产物:
candidates/set1_candidates.jsoncandidates/set1_candidates.md
这里的脚本只做最小硬过滤:
- 影响因子下限
- 用户明确排除的期刊
- 基础元数据整理(JIF、分区、OA 比例、引用量)
不要把这一步输出误解为“已经按语义排好序的最终 shortlist”。 你必须读取该候选池,再结合 manuscript 自主规划真正值得进入 Set2 的期刊。
3. 联网核验 scope、官网、分区与质量,得到 Set2
根据 candidates/set1_candidates.json 与 manuscript 画像,自主决定先核验哪些候选,并逐个联网核验:
- 官方网站
- Aims & Scope
- 中科院小类及其分区
- 业内认可度
- 是否存在预警 / 垃圾期刊信号
优先使用:
- 期刊官网
- PubMed / NLM
- 主流出版社页面
- 可信的分区信息来源
把通过核验的期刊写入:
analysis/set2_scope_review.json
模板:templates/scope_review.template.json 核验口径:references/journal-quality-checklist.md
4a. 抓取 Set2 最近 3 个月 PubMed 原始论文证据
运行:
python3 <skill_root>/scripts/fetch_pubmed_recent.py \
--workspace .bensz-api/skills/paper-select-journal/{yyyy-mm-dd-hh-mm} \
--profile .bensz-api/skills/paper-select-journal/{yyyy-mm-dd-hh-mm}/analysis/manuscript_profile.json \
--scope-review .bensz-api/skills/paper-select-journal/{yyyy-mm-dd-hh-mm}/analysis/set2_scope_review.json产物:
pubmed/recent_articles.jsonpubmed/recent_articles.md
这里只提供原始证据,不负责打分或排序。脚本只做 API 调用、XML 解析和按日期整理。
4b. AI 评定主题相似性,决定哪些期刊进入 Set3
你必须同时阅读:
analysis/manuscript_profile.jsonpubmed/recent_articles.json
这里的“AI”指当前执行本 skill 的宿主模型本身:
- Claude Code 中由当前 Claude 会话完成
- Codex 中由当前 Codex 会话完成
- 不要为 Step 4b 额外调用外部 AI API、独立模型服务或单独打分脚本
也就是说,Step 4b 的规划、语义判断、Set3 去留决策和 set3_similarity_review.json 写入,都必须用当前工作环境已提供的 AI 算力原生完成。
逐个判断 Set2 期刊最近 3 个月论文与稿件在以下维度上的语义相关性:
- 主题是否真的对口,而不只是 token 碰撞
- 研究问题是否接近
- 方法学是否接近
- 相关论文数量与密度是否足以支持进入最终推荐
执行时先快速浏览全部 Set2 近期论文形成比较框架,再逐刊做语义判断,最后统一决定 Set3 去留并写出可复核理由。 不要再把这一步退化成机械 token 打分或硬编码加权公式。
把结论写入:
analysis/set3_similarity_review.json
模板:templates/set3_similarity_review.template.json
每个期刊至少要写:
journal_nameinclude_in_set3similarity_assessmentrelevant_articlesirrelevant_articles_countoverall_relevance_level
overall_relevance_level 只允许:
highmediumlownone
5. 形成最终推荐 JSON
基于 analysis/set3_similarity_review.json,把最终最多 10 个期刊写入 analysis/final_recommendations.json。
- 模板:
templates/final_recommendations.template.json - 字段说明:
references/report-schema.md
必须保留:
- 影响因子
- 中科院小类及其分区
- 业内认可度
- 官方网站
- 为什么推荐
- 最近 3 个月类似主题论文
- 每篇证据论文的 AI
relevance
6. 渲染最终 Markdown 报告
运行:
python3 <skill_root>/scripts/render_report.py \
--workspace .bensz-api/skills/paper-select-journal/{yyyy-mm-dd-hh-mm} \
--final-json .bensz-api/skills/paper-select-journal/{yyyy-mm-dd-hh-mm}/analysis/final_recommendations.json最终输出:
reports/paper-select-journal-report.md
如需 --output 覆盖默认文件名,也只能写到当前 run 目录内部,不能把最终 Markdown 报告写到隐藏工作区之外。
最终报告要求
- 所有期刊写在同一个 Markdown 文件里
- 每个期刊使用
#层级,下面按需用##、### - 每个期刊都要写明:影响因子、中科院小类及分区、业内认可度、官方网站、推荐理由,以及最近 3 个月类似主题论文表格和 AI 相关性说明
决策规则
- scope 不匹配,再高 IF 也不要强推
- 有明显预警 / 垃圾期刊风险,直接淘汰
- 近 3 个月没有相似主题论文,不一定淘汰,但推荐度要下调
include_in_set3为false的期刊,不要进入最终推荐- 中科院分区无法可靠核验时,优先换成信息更透明的候选
- 用户未明确偏好时,自主选择最稳妥方案,不要把提问变成阻塞
命令路径说明
<skill_root>表示当前 skill 的真实安装目录。- 不要假设用户当前工作目录里一定有
paper-select-journal/源码副本。 - 如果你已经处在 skill 根目录,也可以直接运行
python3 scripts/...。
参考文件
references/manuscript-profile.mdreferences/journal-quality-checklist.mdreferences/report-schema.md
[Unreleased]
Changed
- 加强工作区边界约束:
shortlist_journals.py、fetch_pubmed_recent.py、render_report.py现在会拒绝读取 run 目录之外的关键 JSON,并确保生成文件始终写回当前 workspace,避免中间产物和最终报告越界散落 fetch_pubmed_recent.py修正 PubMed 文本月份解析,Mar等月份缩写现在会归一化为YYYY-MM-DD,避免近期论文按字符串排序时出现日期错序shortlist_journals.py对非正数limit改为显式报错,不再默默接受0或负数造成候选截断异常scripts/common.py删除上一版语义打分遗留的无效 token 工具函数,并补充工作区路径校验辅助函数,减少“文档已改、底层工具仍保留旧设计”的漂移config.yaml补充directories.plans/tests与reports.manuscript_profile/scope_review/final_json,将测试目录和关键工作文件路径进一步集中化,并将技能版本号0.3.0 → 0.3.1SKILL.md与README.md同步补充 run 目录内 JSON/报告路径约束,以及低 IF 人工例外的使用边界,避免说明与脚本行为不一致- 将 Set1 从“硬编码语义打分 shortlist”调整为“最小硬过滤候选池 + 宿主模型自主规划”:
shortlist_journals.py不再依赖固定关键词/字段权重,而是只保留 IF、排除名单、分区、OA 比例等确定性整理;Set2/Set3 的语义去留继续交给当前 Claude Code / Codex 会话判断 config.yaml删除已不再需要的matching.*语义打分配置,并将技能版本号0.2.2 → 0.3.0templates/manuscript_profile.template.json、references/manuscript-profile.md、SKILL.md与README.md改为鼓励使用简洁自然语言画像,不再要求为了脚本打分而拆很多field_hints / method_terms / application_termstests/unit/test_shortlist_journals.py改为验证最小硬过滤、显式排除与人工例外行为,不再绑定旧的语义打分公式
[0.2.2] - 2026-04-05
Changed
- 压缩
SKILL.md与references/*.md的工作型 Markdown 表达,去掉重复铺垫,保留触发语义、Set1→Set3 流程、关键命令、输出文件名与 Step 4b 必须由当前宿主模型原生完成的边界
[0.2.1] - 2026-04-05
Changed
SKILL.md与README.md明确 Step 4b 的 AI 评定必须由当前宿主环境中的模型原生完成,也就是 Claude Code / Codex 当前会话自身承担规划与语义判断plans/v20260405190757.md同步补充“AI 算力来自 skill 工作环境、不额外调用外部 AI API”的设计边界,避免把 Step 4b 误解为需要另起模型服务
[0.2.0] - 2026-04-05
Added
- 新增
paper-select-journalskill:支持从 manuscript 生成语义画像、基于内置2023IF.xlsx进行 Set1 初筛、对 Set2 期刊批量抓取最近 3 个月 PubMed 论文,并把最终推荐渲染成单一 Markdown 报告
Changed
render_report.py的最终推荐数量上限改为读取config.yaml:screening.default_set3_limit,不再硬编码10SKILL.md与README.md的脚本示例统一改为<skill_root>/scripts/...口径,避免系统级安装后路径误导fetch_pubmed_recent.py的中间 Markdown 标题从“Set3”修正为“Set2 PubMed 近 3 个月论文证据”,与实际阶段一致
Fixed
- 修复 PubMed 批量检索在单刊失败时会中断整批流程的问题,改为逐刊记录
error并继续 - 修复
shortlist_journals.py中 manual exception 评分未复用主 profile、去重逻辑可能覆盖更优候选的问题 - 修复最终报告未过滤空期刊名的问题,并补充对应单元测试
- 清理
.DS_Store、__pycache__与 fixture 隐藏工作区运行产物,并在.gitignore中新增paper-select-journalfixture 隐藏工作区忽略规则
Added
- 新增
templates/set3_similarity_review.template.json,用于承载 AI 对 Set2→Set3 的语义相似性评估结果
Changed
fetch_pubmed_recent.py不再使用硬编码 token 重叠公式计算相似度,改为只抓取和整理 PubMed 原始论文证据,并按发表日期降序输出SKILL.md的 Step 4 拆分为 4a(脚本抓取原始 PubMed 证据)和 4b(AI 语义评定 Set3),并新增set3_similarity_review.json工作流说明final_recommendations.template.json、render_report.py与report-schema.md改为展示每篇证据论文的 AIrelevance,不再依赖脚本分数README.md同步更新为“脚本抓取 + AI 语义判断”的新流程说明config.yaml删除不再需要的 PubMed 硬编码相似度权重,并新增reports.set3_similarity_review
[0.1.0] - 2026-04-05
Added
- 初始版本:新增
SKILL.md、README.md、config.yaml、assets/journal_catalog/2023IF.xlsx - 新增
scripts/init_workspace.py、scripts/shortlist_journals.py、scripts/fetch_pubmed_recent.py、scripts/render_report.py - 新增
templates/*.json与references/*.md,用于稳定输出manuscript_profile.json、set2_scope_review.json与final_recommendations.json - 新增
tests/unit/单元测试与tests/paper-select-journal/轻量测试夹具
skill_info:
name: "paper-select-journal"
version: "0.3.1"
description: "根据 manuscript 与用户需求筛选合适的 SCI 投稿期刊,并输出带证据的排序报告。"
author: "Bensz Conan"
category: "academic-writing"
workspace:
hidden_dir: ".bensz-api/skills/paper-select-journal"
run_prefix: ""
timestamp_format: "%Y-%m-%d-%H-%M"
latest_run_pointer: "latest-run.txt"
keep_intermediates_inside_hidden_dir: true
subdirs:
- "input"
- "output"
- "log"
- "inputs"
- "analysis"
- "candidates"
- "pubmed"
- "reports"
- "logs"
directories:
plans: "plans"
tests: "tests"
default_test_dir: "tests/paper-select-journal"
assets:
catalog_xlsx: "assets/journal_catalog/2023IF.xlsx"
source_catalog_xlsx: "/Volumes/2T01/winE/RCloud/RFactory/easySCI/raw/2023IF.xlsx"
screening:
min_impact_factor: 3.0
default_set1_limit: 40
default_set2_limit: 20
default_set3_limit: 10
manual_exception_key: "manual_exceptions"
require_quality_screening_after_set1: true
prefer_verified_cas_data_in_final: true
open_access_gold_threshold: 40.0
pubmed:
lookback_days: 90
max_results_per_journal: 12
top_articles_per_journal: 5
retmax: 20
timeout_seconds: 30
delay_seconds_between_requests: 0.34
esearch_url: "https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esearch.fcgi"
efetch_url: "https://eutils.ncbi.nlm.nih.gov/entrez/eutils/efetch.fcgi"
tool_name: "paper-select-journal"
reports:
run_manifest: "run-manifest.json"
manuscript_profile: "analysis/manuscript_profile.json"
set1_json: "candidates/set1_candidates.json"
set1_markdown: "candidates/set1_candidates.md"
scope_review: "analysis/set2_scope_review.json"
pubmed_json: "pubmed/recent_articles.json"
pubmed_markdown: "pubmed/recent_articles.md"
set3_similarity_review: "analysis/set3_similarity_review.json"
final_json: "analysis/final_recommendations.json"
final_report: "reports/paper-select-journal-report.md"
scripts:
init_workspace: "scripts/init_workspace.py"
shortlist_journals: "scripts/shortlist_journals.py"
fetch_pubmed_recent: "scripts/fetch_pubmed_recent.py"
render_report: "scripts/render_report.py"
paper-select-journal — 用户使用指南
本 README 面向使用者:如何触发并正确使用 paper-select-journal skill。 执行规范看 SKILL.md,默认参数看 config.yaml。
快速开始
推荐 Prompt:
请使用 paper-select-journal skill 帮我的论文筛选合适投稿的 SCI 期刊。
输入:论文全文/摘要/稿件文件 + 我的投稿偏好(如果有)
输出:1 份 Markdown 选刊报告,按推荐度排序,最多 10 个期刊。进阶 Prompt:
请使用 paper-select-journal skill 帮我的论文筛选合适投稿的 SCI 期刊。
输入:论文全文/摘要/稿件文件 + 我的投稿偏好
输出:1 份 Markdown 选刊报告,按推荐度排序,最多 10 个期刊。
另外,还有下列参数约束:
- 优先考虑审稿较快的期刊
- 不要推荐预警或垃圾期刊
- 优先保留最近 3 个月发表过相似主题研究的期刊这个 skill 会做什么
它不是只给一个“拍脑袋期刊名”。
它会按 5 步证据推进:
1. 先理解 manuscript,写出简洁但足够表达意图的稿件画像。 2. 用内置的 2023IF.xlsx 做最小硬过滤,生成 Set1 候选池。 3. 再由 AI 基于稿件画像自主规划,决定真正优先核验哪些候选。 4. 联网核验期刊官网、scope、业内认可度、中科院分区与风险信号,收敛到 Set2。 5. 抓取 Set2 最近 3 个月 PubMed 原始论文,并由 AI 做语义相关性判断,筛出真正值得进入最终报告的期刊。
输出文件
默认会在你的当前工作目录下创建隐藏工作区:
.bensz-api/skills/paper-select-journal/{yyyy-mm-dd-hh-mm}/
最终报告默认在:
.bensz-api/skills/paper-select-journal/{yyyy-mm-dd-hh-mm}/reports/paper-select-journal-report.md
中间文件也都留在隐藏目录里,不会散落到外面。 一旦你开始用这些脚本串流程,manuscript_profile.json、set2_scope_review.json、final_recommendations.json 也应继续放在当前 run 目录里,不要挪到隐藏工作区外。
常见使用场景
场景 1:我只有摘要
请使用 paper-select-journal skill。
输入:下面这段摘要 + 我希望优先考虑肿瘤学相关 SCI 期刊
输出:Markdown 选刊报告场景 2:我有完整 manuscript 文件
请使用 paper-select-journal skill 帮我选刊。
输入:`/path/to/manuscript.tex`
输出:Markdown 选刊报告,最多 10 个期刊。场景 3:我还想加主观偏好
请使用 paper-select-journal skill 帮我选刊。
输入:论文全文 + 下列偏好
- 不要版面费特别高的期刊
- 优先 scope 比较聚焦的期刊
- 如果近期没发过类似主题论文,排名就往后放
输出:Markdown 选刊报告输出里会包含什么
每个期刊至少会写:
- 影响因子
- 中科院小类及其分区
- 业内认可度
- 官方网站
- 为什么推荐
- 最近 3 个月类似主题论文表格
- 每篇证据论文与稿件的 AI 相关性说明
备选用法
如果你想手动跑脚本,常用入口是:
python3 <skill_root>/scripts/init_workspace.py --project-root .
python3 <skill_root>/scripts/shortlist_journals.py --workspace <run_dir> --profile <profile_json>
python3 <skill_root>/scripts/fetch_pubmed_recent.py --workspace <run_dir> --profile <profile_json> --scope-review <set2_json>
python3 <skill_root>/scripts/render_report.py --workspace <run_dir> --final-json <final_json>这里的 <skill_root> 指 paper-select-journal 的真实安装目录,不要默认它就在你当前工作目录里。 如果你给 render_report.py 传 --output,输出路径也必须仍在 <run_dir> 里面。
其中 shortlist_journals.py 现在只负责最小硬过滤和候选池整理,不再用固定权重做语义打分。最终先看哪些候选、哪些进入推荐,由 AI 在后续步骤中自主规划。
fetch_pubmed_recent.py 只负责抓取并整理原始 PubMed 论文,不再用硬编码公式给论文打“相似度分”。最终是否进入推荐,由 AI 在后续步骤中做语义判断。
这里的 AI 不是额外接一个新的模型服务,而是直接使用当前运行 skill 的宿主环境算力:
- 如果你在 Claude Code 里运行,就由当前 Claude 会话完成判断
- 如果你在 Codex 里运行,就由当前 Codex 会话完成判断
- 默认不需要再接外部 AI API 来做 Set3 评定
WHICHMODEL
截至 2026-04-05,建议优先使用“推理稳定、联网能力可靠、长上下文够用”的模型来跑这个 skill,因为它同时包含:
- 长文理解
- 期刊匹配判断
- 联网核验
- 证据整合与排序
推荐口径:
- 默认首选:
Claude Sonnet 4.5或gpt-5.4 - 适合完整跑一轮选刊:读 manuscript、生成画像、联网核验、整合证据、排序出最终报告。
- 预算更紧但仍需可靠推理:
Claude Haiku 4.5或gpt-5.4-mini - 更适合已经有较完整
manuscript_profile.json之后的复跑、补跑或报告微调。 - 不建议用于完整主流程:更轻量的 nano 级模型
- 它们适合高吞吐简单任务,但不适合“选刊 + 联网核验 + 证据整合”这种高判断密度流程。
简单理解:
- 你要的是“第一次认真选刊”时,用
Claude Sonnet 4.5或gpt-5.4 - 你要的是“同一篇稿子改几个偏好后再重跑”,可以考虑
Claude Haiku 4.5或gpt-5.4-mini
FAQ
为什么不是直接从 IF 最高开始推荐?
因为高 IF 不等于 scope 合适。这个 skill 默认把“AI 主导的 topic fit 判断 + 期刊质量 + 近期发文证据”放在更高优先级,而不是依赖一套固定关键词权重。
如果只找到 4-5 个很稳的候选怎么办?
那就只输出 4-5 个。这个 skill 默认宁缺毋滥。
为什么还要查最近 3 个月 PubMed?
因为这能更直接验证“这个期刊最近是否真的在发类似主题”,比单看 scope 更贴近真实投稿环境。现在脚本只负责把最近论文抓回来,是否真正“相似”交给 AI 做语义判断,能减少硬编码误判。
Step 4b 的 AI 算力来自哪里?
默认就来自当前承载这个 skill 的工作环境,也就是 Claude Code 或 Codex 自己的模型能力。这个 skill 的设计前提不是“再起一个外部 AI 服务”,而是让宿主模型在拿到 manuscript_profile.json 和 pubmed/recent_articles.json 后,自主规划并完成 Set3 判断。
期刊质量核验清单
Set1 只是基于 2023IF.xlsx 的初筛,不能直接当最终推荐。
必查项
- 官方网站是否真实、可访问、信息完整
- Aims & Scope 是否与 manuscript 真正匹配
- 近 3 个月是否发表过相似主题研究
- 是否存在当前预警信号
- 是否存在垃圾期刊信号
- 中科院小类及分区是否能被可靠来源核验
推荐信号
- 官网页面清楚展示编辑部、审稿流程、伦理规范
- 被主流数据库稳定收录
- 近期文章主题与 manuscript 接近
- 领域研究者经常引用或投稿
风险信号
- scope 过宽且几乎什么都收
- 官网信息破碎,伦理和编辑信息含糊
- 大量模板化特刊或异常夸张的接收承诺
- 搜索时出现较强的预警 / predatory 讨论
处理原则
- 有明显风险,直接淘汰,不要勉强保留
- scope 不够贴,宁可舍弃高 IF,也不要强推
- 中科院分区无法可靠核验时,优先换更透明的候选
Manuscript Profile 指南
analysis/manuscript_profile.json 是后续筛刊的语义起点,先理解 manuscript 再写。
最低字段
titleabstractkeywordsmanuscript_summary
推荐写法
keywords保留稿件原生关键词即可,不必为了脚本打分额外凑词。manuscript_summary用2-4句概括研究问题、主要方法、核心贡献和目标读者。- 如果用户已经说明投稿偏好,把它们写进
target_journal_brief或notes,让宿主模型在后续步骤自主规划。 - 不需要为了 Set1 人工拆出一长串
field_hints / method_terms / application_terms;除非它们确实能帮助人类复核,否则宁可少写。
excluded_journals 何时填写
- 用户明确说“不投某刊”
- 已知合作/伦理/版面费等原因需要回避某刊
- 需要提前排除曾拒稿且不想重复尝试的期刊
最终报告字段说明
analysis/final_recommendations.json 至少包含:
manuscript_titlesummaryjournals
每个 journal 建议包含:
rankrecommendation_scorejournal_nameimpact_factorcas_small_category_quartilerecognitionofficial_websitescope_fit_summaryrecommendation_reasonsrecent_articles
recent_articles 每条建议包含:
publication_datetitleabstractrelevancepmidurl
from __future__ import annotations
import datetime as dt
import json
import re
from pathlib import Path
from typing import Any
import yaml
def skill_root() -> Path:
return Path(__file__).resolve().parents[1]
def load_config(root: Path | None = None) -> dict[str, Any]:
config_path = (root or skill_root()) / "config.yaml"
return yaml.safe_load(config_path.read_text(encoding="utf-8"))
def ensure_dir(path: Path) -> Path:
path.mkdir(parents=True, exist_ok=True)
return path
def write_json(path: Path, payload: Any) -> None:
ensure_dir(path.parent)
path.write_text(
json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=False) + "\n",
encoding="utf-8",
)
def read_json(path: Path) -> Any:
return json.loads(path.read_text(encoding="utf-8"))
def write_text(path: Path, text: str) -> None:
ensure_dir(path.parent)
path.write_text(text, encoding="utf-8")
def resolve_path(path_str: str, base: Path | None = None) -> Path:
path = Path(path_str).expanduser()
if not path.is_absolute():
path = (base or Path.cwd()) / path
return path.resolve()
def is_within(parent: Path, child: Path) -> bool:
parent_resolved = parent.resolve()
child_resolved = child.resolve()
return child_resolved == parent_resolved or parent_resolved in child_resolved.parents
def require_within(parent: Path, child: Path, label: str) -> Path:
if not is_within(parent, child):
raise ValueError(f"{label} 必须位于工作区内: {child}")
return child
def resolve_path_within(path_str: str, *, parent: Path, label: str, base: Path | None = None) -> Path:
return require_within(parent.resolve(), resolve_path(path_str, base=base), label)
def workspace_output_path(workspace: Path, relative_path: str) -> Path:
return require_within(workspace.resolve(), (workspace / relative_path).resolve(), "输出路径")
def generate_run_id(config: dict[str, Any], now: dt.datetime | None = None) -> str:
workspace_cfg = config["workspace"]
stamp = (now or dt.datetime.now()).strftime(workspace_cfg["timestamp_format"])
return f"{workspace_cfg['run_prefix']}{stamp}"
def allocate_unique_run_id(workspace_base: Path, run_id: str) -> str:
if not (workspace_base / run_id).exists():
return run_id
for idx in range(2, 100):
candidate = f"{run_id}-{idx:02d}"
if not (workspace_base / candidate).exists():
return candidate
raise ValueError(f"无法在 {workspace_base} 下分配唯一工作目录: {run_id}")
def normalize_text(text: str | None) -> str:
if not text:
return ""
collapsed = re.sub(r"[^a-z0-9]+", " ", text.lower())
return re.sub(r"\s+", " ", collapsed).strip()
def float_or_none(value: Any) -> float | None:
if value in (None, ""):
return None
try:
return float(value)
except (TypeError, ValueError):
return None
def quartile_from_category(category: str | None) -> str:
text = (category or "").upper()
for quartile in ("Q1", "Q2", "Q3", "Q4"):
if quartile in text:
return quartile
return "unknown"
def slugify(text: str, max_length: int = 80) -> str:
slug = normalize_text(text).replace(" ", "-")
slug = re.sub(r"-{2,}", "-", slug).strip("-")
return slug[:max_length] or "item"
def compact_text(text: str | None, max_chars: int = 220) -> str:
cleaned = re.sub(r"\s+", " ", (text or "")).strip()
if len(cleaned) <= max_chars:
return cleaned
return cleaned[: max_chars - 1].rstrip() + "…"
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import datetime as dt
import json
import time
import urllib.parse
import urllib.request
import xml.etree.ElementTree as ET
from typing import Any
from common import (
compact_text,
load_config,
read_json,
resolve_path,
resolve_path_within,
workspace_output_path,
write_json,
write_text,
)
MONTH_MAP = {
"1": "01",
"01": "01",
"jan": "01",
"january": "01",
"2": "02",
"02": "02",
"feb": "02",
"february": "02",
"3": "03",
"03": "03",
"mar": "03",
"march": "03",
"4": "04",
"04": "04",
"apr": "04",
"april": "04",
"5": "05",
"05": "05",
"may": "05",
"6": "06",
"06": "06",
"jun": "06",
"june": "06",
"7": "07",
"07": "07",
"jul": "07",
"july": "07",
"8": "08",
"08": "08",
"aug": "08",
"august": "08",
"9": "09",
"09": "09",
"sep": "09",
"sept": "09",
"september": "09",
"10": "10",
"oct": "10",
"october": "10",
"11": "11",
"nov": "11",
"november": "11",
"12": "12",
"dec": "12",
"december": "12",
}
def build_esearch_url(config: dict[str, Any], journal_name: str, start_date: dt.date, end_date: dt.date) -> str:
params = {
"db": "pubmed",
"retmode": "json",
"retmax": str(config["pubmed"]["retmax"]),
"sort": "pub_date",
"tool": config["pubmed"]["tool_name"],
"term": f'"{journal_name}"[Journal] AND ("{start_date:%Y/%m/%d}"[Date - Publication] : "{end_date:%Y/%m/%d}"[Date - Publication])',
}
return config["pubmed"]["esearch_url"] + "?" + urllib.parse.urlencode(params)
def build_efetch_url(config: dict[str, Any], ids: list[str]) -> str:
params = {
"db": "pubmed",
"retmode": "xml",
"tool": config["pubmed"]["tool_name"],
"id": ",".join(ids),
}
return config["pubmed"]["efetch_url"] + "?" + urllib.parse.urlencode(params)
def http_get(url: str, timeout: int) -> str:
with urllib.request.urlopen(url, timeout=timeout) as response:
return response.read().decode("utf-8")
def search_pubmed_ids(config: dict[str, Any], journal_name: str, start_date: dt.date, end_date: dt.date) -> list[str]:
payload = json.loads(http_get(build_esearch_url(config, journal_name, start_date, end_date), int(config["pubmed"]["timeout_seconds"])))
return payload.get("esearchresult", {}).get("idlist", [])
def _normalize_month(value: str | None) -> str:
text = str(value or "").strip().lower()
if not text:
return "01"
return MONTH_MAP.get(text, "01")
def _extract_pub_date(article: ET.Element) -> str:
article_date = article.find(".//Article/ArticleDate")
if article_date is not None:
year = article_date.findtext("Year", default="1900")
month = _normalize_month(article_date.findtext("Month", default="01"))
day = article_date.findtext("Day", default="01")
return f"{year}-{month.zfill(2)}-{day.zfill(2)}"
pub_date = article.find(".//JournalIssue/PubDate")
if pub_date is None:
return ""
year = pub_date.findtext("Year", default="1900")
month = _normalize_month(pub_date.findtext("Month", default="01"))
day = pub_date.findtext("Day", default="01")
return f"{year}-{month.zfill(2)}-{day.zfill(2)}"
def parse_pubmed_articles(xml_payload: str) -> list[dict[str, Any]]:
root = ET.fromstring(xml_payload)
articles: list[dict[str, Any]] = []
for article in root.findall(".//PubmedArticle"):
title_node = article.find(".//ArticleTitle")
title = "".join(title_node.itertext()).strip() if title_node is not None else ""
pmid = article.findtext(".//PMID", default="").strip()
abstract_parts: list[str] = []
for node in article.findall(".//Abstract/AbstractText"):
label = node.attrib.get("Label", "").strip()
text = "".join(node.itertext()).strip()
if not text:
continue
abstract_parts.append(f"{label}: {text}" if label else text)
abstract = " ".join(abstract_parts).strip()
articles.append(
{
"pmid": pmid,
"title": title,
"abstract": abstract,
"journal": article.findtext(".//Journal/Title", default="").strip(),
"publication_date": _extract_pub_date(article),
"url": f"https://pubmed.ncbi.nlm.nih.gov/{pmid}/" if pmid else "",
}
)
return articles
def fetch_articles_for_journal(
*,
config: dict[str, Any],
journal_name: str,
start_date: dt.date,
end_date: dt.date,
) -> list[dict[str, Any]]:
ids = search_pubmed_ids(config, journal_name, start_date, end_date)
if not ids:
return []
xml_payload = http_get(build_efetch_url(config, ids), int(config["pubmed"]["timeout_seconds"]))
articles = parse_pubmed_articles(xml_payload)
articles.sort(key=lambda item: item.get("publication_date") or "", reverse=True)
return articles[: int(config["pubmed"]["top_articles_per_journal"])]
def normalize_scope_review(payload: Any) -> list[dict[str, Any]]:
if isinstance(payload, dict):
journals = payload.get("journals", [])
else:
journals = payload
return [journal for journal in journals if journal.get("include_in_set2", True)]
def build_pubmed_summary(
*,
config: dict[str, Any],
scope_review: list[dict[str, Any]],
end_date: dt.date,
) -> dict[str, Any]:
start_date = end_date - dt.timedelta(days=int(config["pubmed"]["lookback_days"]))
journals_output: list[dict[str, Any]] = []
for index, journal in enumerate(scope_review):
journal_name = str(journal.get("journal_name") or "").strip()
entry = {
"journal_name": journal_name,
"scope_fit_summary": journal.get("scope_fit_summary", ""),
"official_website": journal.get("official_website", ""),
"articles": [],
}
if not journal_name:
entry["error"] = "缺少 journal_name,已跳过"
journals_output.append(entry)
continue
try:
entry["articles"] = fetch_articles_for_journal(
config=config,
journal_name=journal_name,
start_date=start_date,
end_date=end_date,
)
except Exception as exc:
entry["error"] = f"{type(exc).__name__}: {exc}"
journals_output.append(entry)
if index < len(scope_review) - 1:
time.sleep(float(config["pubmed"]["delay_seconds_between_requests"]))
return {
"generated_at": dt.datetime.now().isoformat(timespec="seconds"),
"start_date": start_date.isoformat(),
"end_date": end_date.isoformat(),
"journals": journals_output,
}
def render_markdown(summary: dict[str, Any]) -> str:
lines = [
"# Set2 PubMed 近 3 个月论文证据",
"",
f"- 生成时间:{summary['generated_at']}",
f"- 检索时间窗:{summary['start_date']} 至 {summary['end_date']}",
"",
]
for journal in summary["journals"]:
lines.append(f"## {journal['journal_name']}")
lines.append("")
if journal.get("error"):
lines.append(f"- 检索失败:{journal['error']}")
lines.append("")
continue
if not journal["articles"]:
lines.append("- PubMed 最近 3 个月未检索到可用论文,或该刊在 PubMed 收录不足。")
lines.append("")
continue
lines.append("| 日期 | 标题 | 摘要 | PMID |")
lines.append("| --- | --- | --- | --- |")
for article in journal["articles"]:
lines.append(
"| {date} | {title} | {abstract} | [PMID {pmid}]({url}) |".format(
date=article["publication_date"] or "未知",
title=compact_text(article["title"], 100).replace("|", "/"),
abstract=compact_text(article.get("abstract", ""), 140).replace("|", "/"),
pmid=article["pmid"] or "N/A",
url=article["url"] or "https://pubmed.ncbi.nlm.nih.gov/",
)
)
lines.append("")
return "\n".join(lines) + "\n"
def main() -> None:
parser = argparse.ArgumentParser(description="抓取 Set2 期刊最近 3 个月 PubMed 论文")
parser.add_argument("--workspace", required=True, help="隐藏工作区 run 根目录")
parser.add_argument("--profile", required=True, help="manuscript_profile.json 路径")
parser.add_argument("--scope-review", required=True, help="set2_scope_review.json 路径")
parser.add_argument("--today", default="", help="覆盖今天日期,格式 YYYY-MM-DD")
args = parser.parse_args()
config = load_config()
workspace = resolve_path(args.workspace)
_ = read_json(resolve_path_within(args.profile, parent=workspace, label="profile"))
scope_review = normalize_scope_review(
read_json(resolve_path_within(args.scope_review, parent=workspace, label="scope-review"))
)
end_date = dt.date.fromisoformat(args.today) if args.today else dt.date.today()
payload = build_pubmed_summary(
config=config,
scope_review=scope_review,
end_date=end_date,
)
json_path = workspace_output_path(workspace, config["reports"]["pubmed_json"])
markdown_path = workspace_output_path(workspace, config["reports"]["pubmed_markdown"])
write_json(json_path, payload)
write_text(markdown_path, render_markdown(payload))
print(f"pubmed_json={json_path}")
print(f"pubmed_markdown={markdown_path}")
if __name__ == "__main__":
main()
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import datetime as dt
import json
from pathlib import Path
from common import allocate_unique_run_id, ensure_dir, generate_run_id, is_within, load_config, resolve_path, skill_root
def build_manifest(
*,
config: dict,
project_root: Path,
workspace_base: Path,
workspace_root: Path,
run_id: str,
) -> dict:
reports_cfg = config["reports"]
return {
"created_at": dt.datetime.now().isoformat(timespec="seconds"),
"skill_root": str(skill_root()),
"project_root": str(project_root),
"workspace_base": str(workspace_base),
"workspace_root": str(workspace_root),
"run_id": run_id,
"workspace_inside_project_root": is_within(project_root, workspace_base),
"catalog_xlsx": str((skill_root() / config["assets"]["catalog_xlsx"]).resolve()),
"final_report": str((workspace_root / reports_cfg["final_report"]).resolve()),
}
def main() -> None:
parser = argparse.ArgumentParser(description="初始化 paper-select-journal 隐藏工作区")
parser.add_argument("--project-root", default=".", help="用户当前项目根目录")
parser.add_argument("--workspace-base", default="", help="自定义隐藏工作区根目录")
parser.add_argument("--run-id", default="", help="显式指定 run 目录名")
args = parser.parse_args()
config = load_config()
project_root = resolve_path(args.project_root)
default_hidden_dir = str(config["workspace"]["hidden_dir"])
workspace_base = (
resolve_path(args.workspace_base, base=project_root)
if args.workspace_base
else (project_root / default_hidden_dir).resolve()
)
run_id = args.run_id or allocate_unique_run_id(workspace_base, generate_run_id(config))
workspace_root = ensure_dir(workspace_base / run_id)
for subdir in config["workspace"]["subdirs"]:
ensure_dir(workspace_root / str(subdir))
latest_run = workspace_base / config["workspace"]["latest_run_pointer"]
ensure_dir(workspace_base)
latest_run.write_text(run_id + "\n", encoding="utf-8")
manifest = build_manifest(
config=config,
project_root=project_root,
workspace_base=workspace_base,
workspace_root=workspace_root,
run_id=run_id,
)
manifest_path = workspace_root / config["reports"]["run_manifest"]
manifest_path.write_text(
json.dumps(manifest, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
print(f"project_root={project_root}")
print(f"workspace_base={workspace_base}")
print(f"workspace_root={workspace_root}")
print(f"run_id={run_id}")
print(f"manifest={manifest_path}")
if not manifest["workspace_inside_project_root"]:
print("warning=workspace_base 位于 project_root 之外;只有用户明确指定时才应这样做")
if __name__ == "__main__":
main()
#!/usr/bin/env python3
from __future__ import annotations
import argparse
from typing import Any
from common import (
compact_text,
load_config,
read_json,
resolve_path,
resolve_path_within,
workspace_output_path,
write_text,
)
def normalize_final_payload(payload: Any, limit: int = 10) -> dict[str, Any]:
if isinstance(payload, dict):
journals = payload.get("journals", [])
title = payload.get("manuscript_title", "")
notes = payload.get("summary", "")
else:
journals = payload
title = ""
notes = ""
journals = [item for item in journals if str(item.get("journal_name") or "").strip()]
journals = sorted(
journals,
key=lambda item: (
-(item.get("recommendation_score") or item.get("rank_score") or 0.0),
item.get("rank", 999),
),
)[:limit]
return {
"manuscript_title": title,
"summary": notes,
"journals": journals,
}
def render_markdown(payload: dict[str, Any]) -> str:
lines = ["# 期刊投稿推荐报告", ""]
if payload["manuscript_title"]:
lines.append(f"- 稿件题目:{payload['manuscript_title']}")
if payload["summary"]:
lines.append(f"- 总结:{payload['summary']}")
if len(lines) > 2:
lines.append("")
for index, journal in enumerate(payload["journals"], start=1):
lines.append(f"# {index}. {journal.get('journal_name', '未命名期刊')}")
lines.append("")
lines.append("## 核心信息")
lines.append("")
lines.append(f"- 影响因子:{journal.get('impact_factor', journal.get('jif', '待补充'))}")
lines.append(
"- 中科院小类及其分区:{value}".format(
value=journal.get("cas_small_category_quartile", journal.get("cas_quartile", "待联网核验"))
)
)
lines.append(f"- 业内认可度:{journal.get('recognition', '待联网核验')}")
official_website = journal.get("official_website", "")
if official_website:
lines.append(f"- 官方网站:[{official_website}]({official_website})")
else:
lines.append("- 官方网站:待联网核验")
lines.append("")
lines.append("## 为什么推荐这个杂志")
lines.append("")
reasons = journal.get("recommendation_reasons", [])
if reasons:
for reason in reasons:
lines.append(f"- {reason}")
else:
lines.append("- 待补充推荐理由")
scope_fit = journal.get("scope_fit_summary", "")
if scope_fit:
lines.append(f"- Scope 匹配摘要:{scope_fit}")
lines.append("")
lines.append("## 最近 3 个月类似主题论文")
lines.append("")
lines.append("| 发表日期 | 标题 | 摘要 | 相关性说明 | PMID / 链接 |")
lines.append("| --- | --- | --- | --- | --- |")
articles = journal.get("recent_articles", [])
if not articles:
lines.append("| 暂无 | 暂无 | 暂无 | 暂无 | 暂无 |")
for article in articles:
url = article.get("url") or ""
pmid = article.get("pmid") or "无"
if url:
link = f"[PMID {pmid}]({url})"
else:
link = pmid
lines.append(
"| {date} | {title} | {abstract} | {relevance} | {link} |".format(
date=article.get("publication_date", "未知"),
title=compact_text(article.get("title", ""), 90).replace("|", "/"),
abstract=compact_text(article.get("abstract", ""), 140).replace("|", "/"),
relevance=compact_text(article.get("relevance", "待 AI 补充"), 120).replace("|", "/"),
link=link,
)
)
lines.append("")
return "\n".join(lines) + "\n"
def main() -> None:
parser = argparse.ArgumentParser(description="把最终推荐 JSON 渲染成 Markdown 报告")
parser.add_argument("--workspace", required=True, help="隐藏工作区 run 根目录")
parser.add_argument("--final-json", required=True, help="final_recommendations.json 路径")
parser.add_argument("--output", default="", help="覆盖默认输出路径")
args = parser.parse_args()
config = load_config()
workspace = resolve_path(args.workspace)
payload = normalize_final_payload(
read_json(resolve_path_within(args.final_json, parent=workspace, label="final-json")),
limit=int(config["screening"]["default_set3_limit"]),
)
output_path = (
resolve_path_within(args.output, parent=workspace, label="output", base=workspace)
if args.output
else workspace_output_path(workspace, config["reports"]["final_report"])
)
write_text(output_path, render_markdown(payload))
print(f"report_markdown={output_path}")
if __name__ == "__main__":
main()
#!/usr/bin/env python3
from __future__ import annotations
import argparse
from pathlib import Path
from typing import Any
from openpyxl import load_workbook
from common import (
compact_text,
float_or_none,
load_config,
quartile_from_category,
read_json,
resolve_path,
resolve_path_within,
skill_root,
workspace_output_path,
write_json,
write_text,
)
REQUIRED_HEADERS = {
"journal_name",
"issn",
"eissn",
"category",
"citations",
"jif",
"jci",
"percentageOAGold",
}
def load_catalog(catalog_path: Path) -> list[dict[str, Any]]:
workbook = load_workbook(catalog_path, read_only=True, data_only=True)
sheet = workbook[workbook.sheetnames[0]]
rows = sheet.iter_rows(values_only=True)
headers = [str(item).strip() if item is not None else "" for item in next(rows)]
missing = REQUIRED_HEADERS.difference(headers)
if missing:
raise ValueError(f"catalog 缺少列: {sorted(missing)}")
catalog: list[dict[str, Any]] = []
for row_values in rows:
row = dict(zip(headers, row_values))
catalog.append(
{
"journal_name": str(row.get("journal_name") or "").strip(),
"issn": str(row.get("issn") or "").strip(),
"eissn": str(row.get("eissn") or "").strip(),
"category": str(row.get("category") or "").strip(),
"citations": row.get("citations"),
"jif": float_or_none(row.get("jif")),
"jci": float_or_none(row.get("jci")),
"percentage_oa_gold": float_or_none(row.get("percentageOAGold")),
}
)
return catalog
def quartile_rank(quartile: str) -> int:
return {"Q1": 0, "Q2": 1, "Q3": 2, "Q4": 3}.get(quartile, 4)
def build_selection_notes(row: dict[str, Any], profile: dict[str, Any], config: dict[str, Any]) -> list[str]:
quartile = quartile_from_category(row["category"])
notes = [f"JIF {row['jif']:.1f} 达到阈值", f"分区信号:{quartile}"]
if profile.get("prefer_open_access") and (row["percentage_oa_gold"] or 0.0) >= float(
config["screening"]["open_access_gold_threshold"]
):
notes.append(f"OA Gold 占比 {row['percentage_oa_gold']:.1f}%")
if row.get("citations"):
notes.append(f"引用量 {row['citations']}")
return notes
def summarize_candidate(row: dict[str, Any], profile: dict[str, Any], config: dict[str, Any]) -> dict[str, Any]:
quartile = quartile_from_category(row["category"])
return {
**row,
"quartile": quartile,
"selection_notes": build_selection_notes(row, profile, config),
"manual_exception": False,
}
def sort_candidates(candidates: list[dict[str, Any]]) -> list[dict[str, Any]]:
return sorted(
candidates,
key=lambda item: (
0 if item.get("manual_exception") else 1,
quartile_rank(str(item.get("quartile") or "unknown")),
-(item.get("jif") or 0.0),
-(float_or_none(item.get("citations")) or 0.0),
str(item.get("journal_name") or "").lower(),
),
)
def apply_manual_exceptions(
*,
catalog: list[dict[str, Any]],
shortlisted: list[dict[str, Any]],
exceptions: list[dict[str, Any]] | None,
profile: dict[str, Any],
config: dict[str, Any],
) -> list[dict[str, Any]]:
if not exceptions:
return shortlisted
journal_index = {item["journal_name"].lower(): item for item in catalog}
deduped: dict[str, dict[str, Any]] = {
item["journal_name"].lower(): item for item in shortlisted
}
for exception in exceptions:
journal_name = str(exception.get("journal_name") or "").strip()
if not journal_name:
continue
row = journal_index.get(journal_name.lower())
if not row:
continue
candidate = {
**summarize_candidate(row, profile, config),
"manual_exception": True,
"manual_exception_reason": str(exception.get("reason") or "").strip(),
}
deduped[journal_name.lower()] = candidate
return sort_candidates(list(deduped.values()))
def build_shortlist(
*,
catalog: list[dict[str, Any]],
profile: dict[str, Any],
config: dict[str, Any],
limit: int,
) -> list[dict[str, Any]]:
if limit <= 0:
raise ValueError(f"limit 必须是正整数,当前值为 {limit}")
min_jif = max(float(config["screening"]["min_impact_factor"]), float(profile.get("target_if_min", 0.0) or 0.0))
excluded = {
str(name).strip().lower()
for name in profile.get("excluded_journals", [])
if str(name).strip()
}
candidates: list[dict[str, Any]] = []
for row in catalog:
if not row["journal_name"]:
continue
if row["journal_name"].lower() in excluded:
continue
if row["jif"] is None or row["jif"] < min_jif:
continue
candidates.append(summarize_candidate(row, profile, config))
return sort_candidates(candidates)[:limit]
def render_markdown(payload: dict[str, Any]) -> str:
lines = [
"# Set1 候选池",
"",
f"- 生成时间:{payload['generated_at']}",
f"- 候选数量:{payload['candidate_count']}",
f"- 最低影响因子阈值:{payload['min_impact_factor']}",
"- 本步骤只做最小硬过滤,不代表已完成语义匹配排序。",
"",
"| 排名 | 期刊 | 类别 | JIF | 分区 | 入池理由 |",
"| --- | --- | --- | --- | --- | --- |",
]
for index, item in enumerate(payload["candidates"], start=1):
clue_text = ";".join(item.get("selection_notes") or []) or "待 AI 进一步判断"
if item.get("manual_exception") and item.get("manual_exception_reason"):
clue_text = f"{clue_text};人工例外:{item['manual_exception_reason']}"
lines.append(
"| {idx} | {name} | {category} | {jif:.1f} | {quartile} | {clues} |".format(
idx=index,
name=item["journal_name"],
category=item["category"].replace("|", "/"),
jif=item["jif"] or 0.0,
quartile=item.get("quartile", "unknown"),
clues=clue_text.replace("|", "/"),
)
)
lines.extend(
[
"",
"## 下一步",
"",
"- 先通读 manuscript 画像与当前候选池,自主决定优先核验顺序。",
"- 对真正值得继续看的候选联网核验:官方 scope、官网链接、业内认可度、预警/垃圾期刊风险。",
"- 只把 scope 真匹配且质量信号干净的期刊写入 `analysis/set2_scope_review.json`。",
"- 不要把当前 Set1 候选池直接当最终推荐清单。",
]
)
return "\n".join(lines) + "\n"
def main() -> None:
parser = argparse.ArgumentParser(description="基于 2023IF.xlsx 生成 Set1 候选池")
parser.add_argument("--profile", required=True, help="manuscript_profile.json 路径")
parser.add_argument("--workspace", required=True, help="隐藏工作区 run 根目录")
parser.add_argument("--catalog", default="", help="覆盖默认内置 2023IF.xlsx")
parser.add_argument("--limit", type=int, default=0, help="Set1 数量上限")
parser.add_argument("--manual-include-json", default="", help="低 IF 例外补录 JSON")
args = parser.parse_args()
config = load_config()
workspace = resolve_path(args.workspace)
profile = read_json(resolve_path_within(args.profile, parent=workspace, label="profile"))
catalog_path = (
resolve_path(args.catalog)
if args.catalog
else (skill_root() / config["assets"]["catalog_xlsx"]).resolve()
)
catalog = load_catalog(catalog_path)
limit = args.limit or int(config["screening"]["default_set1_limit"])
shortlisted = build_shortlist(
catalog=catalog,
profile=profile,
config=config,
limit=limit,
)
if args.manual_include_json:
shortlisted = apply_manual_exceptions(
catalog=catalog,
shortlisted=shortlisted,
exceptions=read_json(resolve_path(args.manual_include_json)),
profile=profile,
config=config,
)[:limit]
payload = {
"generated_at": __import__("datetime").datetime.now().isoformat(timespec="seconds"),
"catalog_path": str(catalog_path),
"profile_title": profile.get("title", ""),
"candidate_count": len(shortlisted),
"min_impact_factor": max(
float(config["screening"]["min_impact_factor"]),
float(profile.get("target_if_min", 0.0) or 0.0),
),
"candidates": shortlisted,
"notes": compact_text(profile.get("notes", "")),
"selection_strategy": "minimal-hard-filtering",
}
json_path = workspace_output_path(workspace, config["reports"]["set1_json"])
markdown_path = workspace_output_path(workspace, config["reports"]["set1_markdown"])
write_json(json_path, payload)
write_text(markdown_path, render_markdown(payload))
print(f"set1_json={json_path}")
print(f"set1_markdown={markdown_path}")
if __name__ == "__main__":
main()
{
"manuscript_title": "请填写稿件标题",
"summary": "1-3 句总述,说明为什么这些期刊最值得优先考虑。",
"journals": [
{
"rank": 1,
"recommendation_score": 9.4,
"journal_name": "示例期刊",
"impact_factor": 8.7,
"cas_small_category_quartile": "材料科学 2 区",
"recognition": "业内认可度高,主流实验室常投",
"official_website": "https://example.org/journal",
"scope_fit_summary": "论文主题与期刊 scope 匹配点。",
"recommendation_reasons": [
"期刊 scope 与论文高度匹配",
"近期发表过相似主题文章",
"不是预警或垃圾期刊"
],
"recent_articles": [
{
"publication_date": "2026-03-01",
"title": "Recent article title",
"abstract": "Recent article abstract",
"relevance": "AI 说明这篇论文为何能支持该期刊进入最终推荐。",
"pmid": "12345678",
"url": "https://pubmed.ncbi.nlm.nih.gov/12345678/"
}
]
}
]
}
{
"title": "请填写论文题目",
"abstract": "请填写论文摘要或 AI 对全文的精炼摘要",
"keywords": [
"关键词1",
"关键词2"
],
"manuscript_summary": "用 2-4 句总结稿件的核心问题、主要方法和目标读者。",
"target_journal_brief": "如果用户有明确偏好,可简述期刊方向、风格、读者群或投稿策略;没有可留空。",
"excluded_journals": [
"明确不想投的期刊"
],
"prefer_open_access": false,
"target_if_min": 3.0,
"notes": "用户的额外偏好,例如审稿速度、是否接受 OA、是否回避版面费过高期刊,以及 AI 对选刊策略的自由规划说明。"
}
{
"journals": [
{
"journal_name": "示例期刊",
"include_in_set2": true,
"official_website": "https://example.org/journal",
"scope_fit_summary": "说明该刊 scope 为什么与论文匹配。",
"recognition": "说明业内认可度,例如主流领域认可、编辑部稳定、非预警。",
"cas_small_category_quartile": "例如:医学 2 区 / 肿瘤学 2 区",
"quality_check": {
"warning_screening_result": "已查,无预警信号",
"predatory_screening_result": "已查,无垃圾期刊信号"
},
"sources": [
{
"label": "官方 scope",
"url": "https://example.org/aims-and-scope"
}
]
}
]
}
{
"journals": [
{
"journal_name": "示例期刊",
"include_in_set3": true,
"similarity_assessment": "该刊近 3 个月发表了 2 篇与稿件主题和方法都较接近的论文,可作为进入最终推荐的重要证据。",
"relevant_articles": [
{
"pmid": "12345678",
"relevance": "研究问题与稿件接近,且使用了相似的实验策略。"
}
],
"irrelevant_articles_count": 3,
"overall_relevance_level": "high"
}
]
}