## File: README.md

<h3 align="center">Zotero-arXiv-Daily</h3>



---



> [!IMPORTANT]
> Please keep an eye on this repo, and merge your forked repo in time when there is any update of this upstream, in order to enjoy new features and fix found bugs.

## 🧐 About <a name = "about"></a>

> Track new scientific researches of your interest by just forking (and staring) this repo!😊

*Zotero-arXiv-Daily* finds arxiv papers that may attract you based on the context of your Zotero library, and then sends the result to your mailbox📮. It can be deployed as Github Action Workflow with **zero cost**, **no installation**, and **few configuration** of Github Action environment variables for daily **automatic** delivery.

## ✨ Features
- Totally free! All the calculation can be done in the Github Action runner locally within its quota (for public repo).
- AI-generated TL;DR for you to quickly pick up target papers.
- Affiliations of the paper are resolved and presented.
- Links of PDF and code implementation (if any) presented in the e-mail.
- List of papers sorted by relevance with your recent research interest.
- Fast deployment via fork this repo and set environment variables in the Github Action Page.
- Support LLM API for generating TL;DR of papers.
- Ignore unwanted Zotero papers using a list of glob patterns.
- Support multiple sources of papers to retrieve:
  - arxiv
  - biorxiv
  - medrxiv
  - chemrxiv

## 📷 Screenshot


## 🚀 Usage
### Quick Start
1. Fork (and star😘) this repo.


2. Set Github Action environment variables.


Below are all the secrets you need to set. They are invisible to anyone including you once they are set, for security.

| Key |Description | Example |
| :---  | :---  | :--- |
| ZOTERO_ID  | User ID of your Zotero account. **User ID is not your username, but a sequence of numbers**Get your ID from [here](https://www.zotero.org/settings/security). You can find it at the position shown in this [screenshot](https://github.com/TideDra/zotero-arxiv-daily/blob/main/assets/userid.png). | 12345678  |
| ZOTERO_KEY | An Zotero API key with read access. Get a key from [here](https://www.zotero.org/settings/security).  | AB5tZ877P2j7Sm2Mragq041H   |
| SENDER | The email account of the SMTP server that sends you email. | abc@qq.com |
| SENDER_PASSWORD | The password of the sender account. Note that it's not necessarily the password for logging in the e-mail client, but the authentication code for SMTP service. Ask your email provider for this.   | abcdefghijklmn |
| RECEIVER | The e-mail address that receives the paper list. | abc@outlook.com |
| OPENAI_API_KEY | API Key when using the API to access LLMs. You can get FREE API for using advanced open source LLMs in [SiliconFlow](https://cloud.siliconflow.cn/i/b3XhBRAm). | sk-xxx |
| OPENAI_API_BASE | API URL when using the API to access LLMs. | https://api.siliconflow.cn/v1 |

Then you should also set a public variable `CUSTOM_CONFIG` for your custom configuration.


Paste the following content into the value of `CUSTOM_CONFIG` variable:
```yaml
zotero:
  user_id: ${oc.env:ZOTERO_ID}
  api_key: ${oc.env:ZOTERO_KEY}
  include_path: null # Or e.g. ["2026/survey/**", "2026/reading-group/**"]

email:
  sender: ${oc.env:SENDER}
  receiver: ${oc.env:RECEIVER}
  smtp_server: smtp.qq.com
  smtp_port: 465
  sender_password: ${oc.env:SENDER_PASSWORD}

llm:
  api:
    key: ${oc.env:OPENAI_API_KEY}
    base_url: ${oc.env:OPENAI_API_BASE}
  api_mode: chat_completion # Or response to use the Responses API.
  generation_kwargs:
    model: gpt-4o-mini

source:
  arxiv:
    category: ["cs.AI","cs.CV","cs.LG","cs.CL"]
    include_cross_list: false # Set to true to include arXiv cross-list papers in these categories.

executor:
  debug: ${oc.env:DEBUG,null}
  source: ['arxiv']
```
Set `source.arxiv.include_cross_list: true` if you want cross-listed papers included.
>[!NOTE]
> `${oc.env:XXX,yyy}` means the value of the environment variable `XXX`. If the variable is not set, the default value `yyy` will be used.

Here is the full configuration, `???` means the value must be filled in:
```yaml
zotero:
  user_id: ??? # User ID of your Zotero account.
  api_key: ??? # An Zotero API key with read access.
  include_path: null # A list of glob patterns marking the Zotero collections that should be included. Example: ["2026/survey/**", "2026/reading-group/**"]

source:
  arxiv:
    category: null # The categories of target arxiv papers. Find the abbr of your research area from [here](https://arxiv.org/category_taxonomy). Example: ["cs.AI","cs.CV","cs.LG","cs.CL"]
    include_cross_list: false # Whether to include arXiv cross-list papers in subscribed categories. Example: true
  biorxiv:
    category: null # The categories of target biorxiv papers. Find categories from [here](https://www.biorxiv.org/). Example: ["biochemistry","animal behavior and cognition"]
  medrxiv:
    category: null # The categories of target medrxiv papers. Find categories from [here](https://www.medrxiv.org/) Example: ["psychiatry and clinical psychology", "neurology"]
  chemrxiv:
    include_new_versions: false # Whether to include revised versions (v2, v3, ...) of previously posted chemrxiv preprints in addition to new first postings. chemrxiv has no category filter: all new preprints (a few dozen per day) are retrieved via Crossref and left to the reranker. Example: true

email:
  sender: ??? # The email account of the SMTP server that sends you email. Example: abc@qq.com
  receiver: ??? # The email account that receives the paper list. Example: abc@outlook.com
  smtp_server: ??? # The SMTP server that sends the email. Ask your email provider (Gmail, QQ, Outlook, ...) for its SMTP server. Example: smtp.qq.com
  smtp_port: ??? # The port of SMTP server. Example: 465
  sender_password: ??? # The password of the sender account. Note that it's not necessarily the password for logging in the e-mail client, but the authentication code for SMTP service. Ask your email provider for this. Example: abcdefghijklmn

llm:
  api:
    key: ??? # API Key of your LLM API. Example: sk-xxx
    base_url: ??? # API URL of your LLM API. Example: https://api.openai.com/v1
  api_mode: chat_completion # The LLM API to use. Options: chat_completion or response.
  generation_kwargs:
  # Arguments for the selected LLM API.
    max_tokens: 16384
    model: ???
  language: English # Preferred language for the TL;DR. Example: English

reranker:
  local:
    model: jinaai/jina-embeddings-v5-text-nano # The Hugging Face model name of the local embedding model. Example: jinaai/jina-embeddings-v5-text-nano
    encode_kwargs:
    # The kwargs for the encode method of the local embedding model. Details see [here](https://www.sbert.net/docs/package_reference/SentenceTransformer.html#sentence_transformers.SentenceTransformer.encode)
      task: retrieval
      prompt_name: document
  api:
    key: null # API Key of your embedding model API. Example: sk-xxx
    base_url: null # API URL of your embedding model API. Example: https://api.openai.com/v1
    model: null # The model name of the embedding model. Example: text-embedding-3-large
    batch_size: null # The batch size for embedding API requests. Adjust to match your provider's limit. Example: 64

executor:
  debug: false # Whether to use debug mode. Example: true
  send_empty: false # Whether to send an empty email even if no new papers today. Example: true
  max_paper_num: 100 # The maximum number of the papers presented in the email. Example: 100
  source: ??? # The sources of papers to retrieve. Example: ['arxiv','biorxiv','medrxiv','chemrxiv']
  reranker: local # The reranker to use. Example: 'local' or 'api'
```

That's all! Now you can test the workflow by manually triggering it:


> [!NOTE]
> The Test-Workflow Action is the debug version of the main workflow (Send-emails-daily), which always retrieve 5 arxiv papers regardless of the date. While the main workflow will be automatically triggered everyday and retrieve new papers released yesterday. There is no new arxiv paper at weekends and holiday, in which case you may see "No new papers found" in the log of main workflow.

Then check the log and the receiver email after it finishes.

By default, the main workflow runs on 22:00 UTC everyday. You can change this time by editting the workflow config `.github/workflows/main.yml`.

### Local Running
Supported by [uv](https://github.com/astral-sh/uv), this workflow can easily run on your local device if uv is installed:
```bash
# set all the environment variables
# export ZOTERO_ID=xxxx
# ...
cd zotero-arxiv-daily
uv run main.py
```

## 🚀 Sync with the latest version
This project is in active development. You can subscribe this repo via `Watch` so that you can be notified once we publish new release.




## 📖 How it works
*Zotero-arXiv-Daily* firstly retrieves all the papers in your Zotero library and all the papers released in the previous day, via corresponding API. Then it calculates the embedding of each paper's abstract via an embedding model. The score of a paper is its weighted average similarity over all your Zotero papers (newer paper added to the library has higher weight). The TLDR of each paper is generated by LLM, given the text extracted by pymupdf4llm.

## 📌 Limitations
- The recommendation algorithm is very simple, it may not accurately reflect your interest. Welcome better ideas for improving the algorithm!
- High `MAX_PAPER_NUM` can lead the execution time exceed the limitation of Github Action runner (6h per execution for public repo, and 2000 mins per month for private repo). Commonly, the quota given to public repo is definitely enough for individual use. If you have special requirements, you can deploy the workflow in your own server, or use a self-hosted Github Action runner, or pay for the exceeded execution time.


## 📃 License
Distributed under the AGPLv3 License. See `LICENSE` for detail.

## ❤️ Acknowledgement
- [pyzotero](https://github.com/urschrei/pyzotero)
- [arxiv](https://github.com/lukasschwab/arxiv.py)
- [sentence_transformers](https://github.com/UKPLab/sentence-transformers)

## ☕ Buy Me A Coffee
If you find this project helpful, welcome to sponsor me via WeChat or via [ko-fi](https://ko-fi.com/tidedra).



## 🌟 Star History

[](https://star-history.com/#TideDra/zotero-arxiv-daily&Date)

---

## File: CLAUDE.md

# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Project Overview

Zotero-arXiv-Daily recommends new arXiv/bioRxiv/medRxiv/chemRxiv papers based on a user's Zotero library. It computes embedding similarity between new papers and the user's existing library, generates TLDRs via LLM, and delivers results by email. Designed to run as a GitHub Actions workflow at zero cost.

## Commands

```bash
# Run the application
uv run src/zotero_arxiv_daily/main.py

# Run tests (excludes slow tests by default)
uv run pytest

# Run all tests including slow ones
uv run pytest -m ""

# Run a single test
uv run pytest tests/test_utils.py::TestGlobMatch -v

# Install/sync dependencies
uv sync
```

No linter or formatter is configured.

## Architecture

The app follows a linear pipeline orchestrated by `Executor` (`src/zotero_arxiv_daily/executor.py`):

1. **Fetch Zotero corpus** — retrieves user's library papers via pyzotero API
2. **Filter corpus** — applies `include_path` glob patterns to select relevant collections
3. **Retrieve new papers** — fetches from configured sources (arXiv RSS, bioRxiv/medRxiv REST API, chemRxiv via Crossref REST API)
4. **Rerank** — scores candidates by weighted similarity to corpus (newer Zotero papers weighted higher)
5. **Generate TLDRs + affiliations** — via OpenAI-compatible LLM API
6. **Render + send email** — HTML email via SMTP

### Plugin Systems

**Retrievers** (`src/zotero_arxiv_daily/retriever/`): Register via `@register_retriever` decorator, discovered by `get_retriever_cls()`. Each retriever implements `_retrieve_raw_papers()` and `convert_to_paper()`.

**Rerankers** (`src/zotero_arxiv_daily/reranker/`): Register via `@register_reranker` decorator, discovered by `get_reranker_cls()`. Two implementations: `local` (sentence-transformers) and `api` (OpenAI-compatible embeddings endpoint).

### Configuration

Uses Hydra + OmegaConf. Config is composed from `config/base.yaml` (defaults) + `config/custom.yaml` (user overrides). Environment variables are interpolated via `${oc.env:VAR_NAME,default}` syntax. Entry point uses `@hydra.main`.

### Data Classes

`Paper` and `CorpusPaper` in `src/zotero_arxiv_daily/protocol.py`. `Paper` has LLM-powered methods (`generate_tldr`, `generate_affiliations`) that call the OpenAI API directly.

## Testing

Tests marked `@pytest.mark.slow` require heavy dependencies (e.g., sentence-transformers model download) and are skipped locally by default (`addopts = "-m 'not slow'"` in pyproject.toml). All other tests run with pure Python stubs (no Docker containers needed).

```bash
# Run tests (excludes slow tests)
uv run pytest

# Run all tests including slow ones
uv run pytest -m ""

# Run with coverage
uv run pytest --cov=src/zotero_arxiv_daily --cov-report=term-missing
```

## gstack

Use the `/browse` skill from gstack for all web browsing. Never use `mcp__claude-in-chrome__*` tools.

Available skills: `/office-hours`, `/plan-ceo-review`, `/plan-eng-review`, `/plan-design-review`, `/design-consultation`, `/design-shotgun`, `/design-html`, `/review`, `/ship`, `/land-and-deploy`, `/canary`, `/benchmark`, `/browse`, `/connect-chrome`, `/qa`, `/qa-only`, `/design-review`, `/setup-browser-cookies`, `/setup-deploy`, `/retro`, `/investigate`, `/document-release`, `/codex`, `/cso`, `/autoplan`, `/plan-devex-review`, `/devex-review`, `/careful`, `/freeze`, `/guard`, `/unfreeze`, `/gstack-upgrade`, `/learn`.

If gstack skills aren't working, run `cd .claude/skills/gstack && ./setup` to build the binary and register skills.

## Git Workflow

- PRs should target the `dev` branch, not `main`
- Current development branch: `dev`

---

## File: assets/use_docker.md

## 🐳 Docker Deployment

For users who prefer containerized deployment, we now provide Docker deployment options. This is particularly useful for:

- Running the service on your own server instead of GitHub Actions
- Better resource control (CPU/RAM allocation)
- Easier environment management
- Persistent logging and model caching
- **Prerequisites**:
  - Docker installed ([Installation Guide](https://docs.docker.com/engine/install/))
  - Docker Compose (usually included with Docker Desktop)
  - Configured Docker image registry mirror (for faster builds in some regions)

### Quick Start with Docker

1. Clone the repository:
```bash
git clone https://github.com/TideDra/zotero-arxiv-daily.git
cd zotero-arxiv-daily
```

2. Build the Docker image (recommended for customization):
```bash
docker build . -t local/zotero-arxiv-daily:latest
```

3. Create necessary directories:
```bash
mkdir -p logs models
```

4. Edit the `docker-compose.yml` file to configure your environment variables:
```yaml
environment:
    environment:
      # 必填参数（示例值）
      - ZOTERO_ID=1234567
      - ZOTERO_KEY=AbCdEfGhIjKlMnOpQrStUvWx
      - SMTP_SERVER=smtp.example.com
      - SMTP_PORT=465
      - SENDER=your_email@example.com
      - SENDER_PASSWORD=your_email_password
      - RECEIVER=receiver_email@example.com

      # 可选参数（带默认值）
      - ZOTERO_IGNORE=already_read_papers
      - ARXIV_QUERY=cs.AI+cs.CV+cs.LG+cs.CL
      - SEND_EMPTY=False
      - MAX_PAPER_NUM=5
      - USE_LLM_API=1
      - OPENAI_API_KEY=sk-your-openai-key-here
      - OPENAI_API_BASE=https://api.openai.com/v1
      - MODEL_NAME=Qwen/Qwen1.5-7B-Instruct
      - LANGUAGE=English
      
      # 新增配置
      - HF_ENDPOINT=https://hf-mirror.com
      # - TZ=Asia/Shanghai  # 时区设置
      # - http_proxy=http://proxy.example.com:8080  # HTTP代理（可选）
      # - https_proxy=http://proxy.example.com:8080 # HTTPS代理（可选）
      # - no_proxy=localhost,127.0.0.1,.internal  # 代理排除项
```

5. Start the service:
```bash
docker compose up -d
```

### Key Features of Docker Deployment

- **Scheduled Execution**: By default runs daily at 8:00 AM (configurable in `command` section)
- **Log Persistence**: All logs are saved in the `logs/` directory
- **Model Caching**: Local LLM models can be cached in `models/` directory
- **Resource Isolation**: Runs in a contained environment with all dependencies included
- **Easy Updates**: Simply rebuild the image when updating the service

### Configuration Options

You can customize the deployment by:

1. **Changing schedule time**: Edit the cron expression in `command` section (default: `0 8 * * *` means 8:00 AM daily)
2. **Using local LLM**: Set `USE_LLM_API=0` and uncomment the models volume
3. **Proxy settings**: Uncomment and configure proxy environment variables if needed
4. **Timezone**: Uncomment `TZ` variable to set specific timezone (you may also need to comment `- /etc/localtime:/etc/localtime:ro`)

### Monitoring and Maintenance

- View logs:
```bash
docker logs zotero-arxiv-daily
```

- Stop the service:
```bash
docker compose down
```

- Update the service:
```bash
git pull origin main
docker compose down
docker compose up -d --build
```

### Why Choose Docker Deployment?

1. **Consistent Environment**: Eliminates "works on my machine" problems
2. **Resource Control**: Allocate specific CPU/RAM resources as needed
3. **Isolation**: Runs separately from your host system
4. **Portability**: Easy to move between different servers
5. **Persistent Storage**: Logs and models persist between container restarts

---

## File: config/base.yaml

zotero:
  user_id: ??? # User ID of your Zotero account.
  api_key: ??? # An Zotero API key with read access.
  include_path: null # A list of glob patterns marking the Zotero collections that should be included. Example: ["2026/survey/**","2026/reading-group/**"]
  ignore_path: null # A list of glob patterns marking the Zotero collections that should be excluded. Example: ["2026/ignore/**","archive/**"]

source:
  arxiv:
    category: null # The categories of target arxiv papers. Find the abbr of your research area from [here](https://arxiv.org/category_taxonomy). Example: ["cs.AI","cs.CV","cs.LG","cs.CL"]
    include_cross_list: false # Whether to include arXiv cross-list papers in subscribed categories. Example: true
  biorxiv:
    category: null # The categories of target biorxiv papers. Find categories from [here](https://www.biorxiv.org/). Example: ["biochemistry","animal behavior and cognition"]
  medrxiv:
    category: null # The categories of target medrxiv papers. Find categories from [here](https://www.medrxiv.org/) Example: ["psychiatry and clinical psychology", "neurology"]
  chemrxiv:
    include_new_versions: false # Whether to include revised versions (v2, v3, ...) of previously posted chemrxiv preprints in addition to new first postings. chemrxiv has no category filter: all new preprints (a few dozen per day) are retrieved via Crossref and left to the reranker. Example: true

email:
  sender: ??? # The email account of the SMTP server that sends you email. Example: abc@qq.com
  receiver: ??? # The email account that receives the paper list. Example: abc@outlook.com
  smtp_server: ??? # The SMTP server that sends the email. Ask your email provider (Gmail, QQ, Outlook, ...) for its SMTP server. Example: smtp.qq.com
  smtp_port: ??? # The port of SMTP server. Example: 465
  sender_password: ??? # The password of the sender account. Note that it's not necessarily the password for logging in the e-mail client, but the authentication code for SMTP service. Ask your email provider for this. Example: abcdefghijklmn

llm:
  api:
    key: ??? # API Key of your LLM API. Example: sk-xxx
    base_url: ??? # API URL of your LLM API. Example: https://api.openai.com/v1
  api_mode: chat_completion # The LLM API to use. Options: chat_completion or response.
  generation_kwargs:
  # Arguments for the selected LLM API.
    max_tokens: 16384
    model: ???
  language: English # Preferred language for the TL;DR. Example: English

reranker:
  local:
    model: jinaai/jina-embeddings-v5-text-nano-retrieval # The Hugging Face model name of the local embedding model. Example: jinaai/jina-embeddings-v5-text-nano
    encode_kwargs:
    # The kwargs for the encode method of the local embedding model. Details see [here](https://www.sbert.net/docs/package_reference/SentenceTransformer.html#sentence_transformers.SentenceTransformer.encode)
      task: retrieval
      prompt_name: document
  api:
    key: null # API Key of your embedding model API. Example: sk-xxx
    base_url: null # API URL of your embedding model API. Example: https://api.openai.com/v1
    model: null # The model name of the embedding model. Example: text-embedding-3-large
    batch_size: null # The batch size for embedding API requests. Adjust to match your provider's limit. Example: 64

executor:
  debug: false # Whether to use debug mode. Example: true
  send_empty: false # Whether to send an empty email even if no new papers today. Example: true
  max_paper_num: 100 # The maximum number of the papers presented in the email. Example: 100
  source: ??? # The sources of papers to retrieve. Example: ['arxiv','biorxiv','medrxiv','chemrxiv']
  reranker: local # The reranker to use. Example: 'local' or 'api'

---

## File: config/custom.yaml

zotero:
  user_id: ${oc.env:ZOTERO_ID}
  api_key: ${oc.env:ZOTERO_KEY}
  include_path: null # Or set a list of glob patterns, e.g. ["2026/survey/**","2026/reading-group/**"]

email:
  sender: ${oc.env:SENDER}
  receiver: ${oc.env:RECEIVER}
  smtp_server: smtp.qq.com
  smtp_port: 465
  sender_password: ${oc.env:SENDER_PASSWORD}

llm:
  api:
    key: ${oc.env:OPENAI_API_KEY}
    base_url: ${oc.env:OPENAI_API_BASE}
  api_mode: chat_completion
  generation_kwargs:
    model: gpt-4o-mini

source:
  arxiv:
    category: ["cs.AI","cs.CV","cs.LG","cs.CL"]

executor:
  debug: ${oc.env:DEBUG,null}
  source: ['arxiv']

---

## File: config/default.yaml

defaults:
  - base
  - custom

---

## File: .github/copilot-instructions.md

# Copilot Instructions

## Project Overview

Zotero-arXiv-Daily recommends new arXiv/bioRxiv/medRxiv papers based on a user's Zotero library. It computes embedding similarity between new papers and the user's existing library, generates TLDRs via LLM, and delivers results by email. Designed to run as a GitHub Actions workflow at zero cost.

## Commands

```bash
# Install/sync dependencies
uv sync

# Run the application
uv run src/zotero_arxiv_daily/main.py

# Run tests (excludes slow tests by default)
uv run pytest

# Run all tests including slow ones
uv run pytest -m ""

# Run a single test
uv run pytest tests/test_utils.py::TestGlobMatch -v
```

No linter or formatter is configured.

## Architecture

The app is a linear pipeline orchestrated by `Executor` (`src/zotero_arxiv_daily/executor.py`):

1. **Fetch Zotero corpus** → pyzotero API
2. **Filter corpus** → `include_path` / `ignore_path` glob patterns
3. **Retrieve new papers** → from configured sources (arXiv RSS, bioRxiv/medRxiv REST API)
4. **Rerank** → weighted embedding similarity to corpus (newer Zotero papers weighted higher)
5. **Generate TLDRs + affiliations** → OpenAI-compatible LLM API
6. **Render + send email** → HTML email via SMTP

### Plugin Systems

**Retrievers** (`src/zotero_arxiv_daily/retriever/`): Register via `@register_retriever("name")` decorator on a `BaseRetriever` subclass. Each retriever implements `_retrieve_raw_papers()` and `convert_to_paper()`. Discovered at runtime via `get_retriever_cls(name)` from a module-level `registered_retrievers` dict.

**Rerankers** (`src/zotero_arxiv_daily/reranker/`): Register via `@register_reranker("name")` decorator on a `BaseReranker` subclass. Two implementations: `local` (sentence-transformers) and `api` (OpenAI-compatible embeddings endpoint). Discovered via `get_reranker_cls(name)`.

When adding a new retriever or reranker, follow the existing pattern: create a new file, subclass the base, apply the registration decorator, and implement the abstract methods.

### Configuration

Uses Hydra + OmegaConf. Config composes from `config/base.yaml` (defaults with `???` placeholders for required values) + `config/custom.yaml` (user overrides). The composition order is defined in `config/default.yaml`. Environment variables are interpolated via `${oc.env:VAR_NAME,default}` syntax. Entry point uses `@hydra.main(config_name="default")`.

### Data Classes

`Paper` and `CorpusPaper` in `src/zotero_arxiv_daily/protocol.py`. `Paper` has LLM-powered methods (`generate_tldr`, `generate_affiliations`) that call the OpenAI API directly with `tiktoken`-based token truncation.

## Testing Conventions

- Tests use **pytest monkeypatch + `SimpleNamespace`** for stubs — not `unittest.mock`.
- A session-scoped Hydra config in `tests/conftest.py` is deep-copied per test via the `config` fixture.
- Canned response factories live in `tests/canned_responses.py` (e.g., `make_stub_openai_client()`, `make_stub_zotero_client()`).
- Tests marked `@pytest.mark.slow` require heavy dependencies (model downloads) and are excluded by default (`addopts = "-m 'not slow'"` in pyproject.toml).
- Monkeypatching targets the module-level import path (e.g., `"zotero_arxiv_daily.executor.zotero.Zotero"`).

## Coding Conventions

- **Logging:** `loguru.logger` throughout — never `print()` or stdlib `logging`.
- **Type hints:** Modern Python 3.10+ syntax (`list[Paper]`, `str | None`).
- **Constants:** Module-level `UPPER_SNAKE_CASE`.
- **Private methods:** Prefixed with `_` (e.g., `_retrieve_raw_papers`).
- **Error handling:** Graceful degradation with try/except and fallback logic; log warnings rather than raising.
- **Config injection:** All major components receive `DictConfig` at init and store it as `self.config`.

## Git Workflow

- PRs should target the **`dev`** branch, not `main`.

---

## File: .github/FUNDING.yml

# These are supported funding model platforms

github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: tidedra
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
polar: # Replace with a single Polar username
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
thanks_dev: # Replace with a single thanks.dev username
custom: https://github.com/TideDra/zotero-arxiv-daily/blob/main/assets/wechat_sponsor.JPG

---

## File: .github/keep-alive.txt

Last run: 2026-10-01 05:07:45 UTC
This file is automatically updated to keep the repository active and prevent GitHub Actions from disabling scheduled workflows.

---

## File: .github/workflows/ci.yml

name: CI
on:
  workflow_dispatch:
  push:
    branches:
      - main

  pull_request:
    branches:
      - main

jobs:
  pytest:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v6

      - name: Setup uv
        uses: astral-sh/setup-uv@v7.1.4

      - name: Run Pytest
        run: |
          uv run pytest -m "" --cov=src/zotero_arxiv_daily --cov-report=term-missing

---

## File: .github/workflows/keep-alive.yml

name: Keep Alive

on:
  workflow_dispatch:
  schedule:
    # Run every 30 days at 00:00 UTC to keep the repository active
    - cron: '0 0 */30 * *'

jobs:
  keep-alive:
    runs-on: ubuntu-latest
    permissions:
      contents: write
    steps:
      - name: Checkout
        uses: actions/checkout@v6
        with:
          fetch-depth: 0

      - name: Update keep-alive file
        run: |
          mkdir -p .github
          echo "Last run: $(date -u +'%Y-%m-%d %H:%M:%S UTC')" > .github/keep-alive.txt
          echo "This file is automatically updated to keep the repository active and prevent GitHub Actions from disabling scheduled workflows." >> .github/keep-alive.txt

      - name: Commit and push if changed
        run: |
          git config user.name "github-actions[bot]"
          git config user.email "github-actions[bot]@users.noreply.github.com"
          git add .github/keep-alive.txt
          if git diff --staged --quiet; then
            echo "No changes to commit"
          else
            git commit -m "chore: update keep-alive timestamp [skip ci]"
            git push
          fi

---

## File: .github/workflows/main.yml

name: Send emails daily
on:
  workflow_dispatch:
  schedule:
    - cron: '0 22 * * *'

jobs:
  calculate-and-send:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v6
        with:
          ref: ${{ vars.REF }}

      - name: Setup uv
        uses: astral-sh/setup-uv@v7

      - name: Run script
        env:
          ZOTERO_ID: ${{ secrets.ZOTERO_ID }}
          ZOTERO_KEY: ${{ secrets.ZOTERO_KEY }}
          SENDER: ${{ secrets.SENDER }}
          RECEIVER: ${{ secrets.RECEIVER }}
          SENDER_PASSWORD: ${{ secrets.SENDER_PASSWORD }}
          OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
          OPENAI_API_BASE: ${{ secrets.OPENAI_API_BASE }}
          CUSTOM_CONFIG: ${{ vars.CUSTOM_CONFIG }}
        run: |
          printf "%b\n" "$CUSTOM_CONFIG" > config/custom.yaml
          echo "Use custom config: "
          cat config/custom.yaml
          uv run src/zotero_arxiv_daily/main.py

---

## File: .github/workflows/test.yml

name: Test
on:
  workflow_dispatch:

jobs:
  calculate-and-send:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v6
      #  with:
      #    repository: ${{ vars.REPOSITORY }}
      #    ref: ${{ vars.REF }}

      - name: Setup uv
        uses: astral-sh/setup-uv@v7.1.4


      - name: Run script
        env:
          ZOTERO_ID: ${{ secrets.ZOTERO_ID }}
          ZOTERO_KEY: ${{ secrets.ZOTERO_KEY }}
          SENDER: ${{ secrets.SENDER }}
          RECEIVER: ${{ secrets.RECEIVER }}
          SENDER_PASSWORD: ${{ secrets.SENDER_PASSWORD }}
          OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
          OPENAI_API_BASE: ${{ secrets.OPENAI_API_BASE }}
          CUSTOM_CONFIG: ${{ vars.CUSTOM_CONFIG }}
        run: |
          export DEBUG=true
          printf "%b\n" "$CUSTOM_CONFIG" > config/custom.yaml
          echo "Use custom config: "
          cat config/custom.yaml
          uv run src/zotero_arxiv_daily/main.py