├── .github └── workflows │ ├── archive-site.yml │ ├── publish.yml │ └── report-issues.yml ├── .gitignore ├── LICENSE ├── README.md ├── feiyue ├── __init__.py ├── backend │ ├── __init__.py │ └── api.py └── frontend │ ├── __init__.py │ ├── latex.py │ └── mkdocs.py ├── maker.py ├── requirements.txt ├── resources ├── latex │ └── manifest.json └── mkdocs │ ├── docs │ ├── CNAME │ ├── contribute.md │ ├── faq.md │ └── feedback.md │ ├── manifest.json │ ├── overrides │ ├── main.html │ └── partials │ │ ├── comments.html │ │ └── copyright.html │ └── stylesheets │ └── extra.css ├── scripts ├── archive_site.py ├── export.sh └── report_issues.py └── templates ├── latex ├── all_areas.jinja ├── applicant.jinja ├── macros.jinja ├── main.jinja ├── major.jinja └── program.jinja └── mkdocs ├── applicant.jinja ├── applicant_index.jinja ├── area_index.jinja ├── index.jinja ├── macros.jinja ├── major.jinja ├── major_index.jinja ├── mkdocs_config.jinja ├── program.jinja └── program_index.jinja /.github/workflows/archive-site.yml: -------------------------------------------------------------------------------- 1 | name: archive-site 2 | on: 3 | schedule: 4 | - cron: '0 0 * * 1' # every Monday at 00:00 UTC 5 | workflow_dispatch: 6 | 7 | # Archiving sites is slow and can exceed the 6 hour limit for a single job. 8 | # To work around this, we split it into multiple jobs. 9 | 10 | jobs: 11 | archive1: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v2 15 | - uses: actions/setup-python@v2 16 | with: 17 | python-version: '3.x' 18 | - run: pip install requests lxml 19 | - run: python scripts/archive_site.py --start=0 --end=50 https://database.feiyue.online/sitemap.xml 20 | 21 | archive2: 22 | runs-on: ubuntu-latest 23 | steps: 24 | - uses: actions/checkout@v2 25 | - uses: actions/setup-python@v2 26 | with: 27 | python-version: '3.x' 28 | - run: pip install requests lxml 29 | - run: python scripts/archive_site.py --start=50 --end=100 https://database.feiyue.online/sitemap.xml 30 | 31 | archive3: 32 | runs-on: ubuntu-latest 33 | steps: 34 | - uses: actions/checkout@v2 35 | - uses: actions/setup-python@v2 36 | with: 37 | python-version: '3.x' 38 | - run: pip install requests lxml 39 | - run: python scripts/archive_site.py --start=100 --end=150 https://database.feiyue.online/sitemap.xml 40 | 41 | archive4: 42 | runs-on: ubuntu-latest 43 | steps: 44 | - uses: actions/checkout@v2 45 | - uses: actions/setup-python@v2 46 | with: 47 | python-version: '3.x' 48 | - run: pip install requests lxml 49 | - run: python scripts/archive_site.py --start=150 --end=200 https://database.feiyue.online/sitemap.xml 50 | 51 | archive5: 52 | runs-on: ubuntu-latest 53 | steps: 54 | - uses: actions/checkout@v2 55 | - uses: actions/setup-python@v2 56 | with: 57 | python-version: '3.x' 58 | - run: pip install requests lxml 59 | - run: python scripts/archive_site.py --start=200 --end=250 https://database.feiyue.online/sitemap.xml 60 | 61 | archive-rest: 62 | runs-on: ubuntu-latest 63 | steps: 64 | - uses: actions/checkout@v2 65 | - uses: actions/setup-python@v2 66 | with: 67 | python-version: '3.x' 68 | - run: pip install requests lxml 69 | - run: python scripts/archive_site.py --start=250 https://database.feiyue.online/sitemap.xml 70 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: publish 2 | on: 3 | push: 4 | branches: 5 | - main 6 | paths-ignore: 7 | - README.md 8 | - LICENSE 9 | - .gitignore 10 | - templates/latex/* 11 | - scripts/report_issues.py 12 | - scripts/archive_site.py 13 | - resources/latex/* 14 | - feiyue/frontend/latex.py 15 | - .github/workflows/* 16 | - '!.github/workflows/publish.yml' 17 | schedule: 18 | - cron: '0 */6 * * *' 19 | workflow_dispatch: 20 | 21 | jobs: 22 | deploy: 23 | runs-on: ubuntu-latest 24 | steps: 25 | - uses: actions/checkout@v2 26 | - uses: actions/setup-python@v2 27 | with: 28 | python-version: 3.x 29 | - run: pip install -r requirements.txt 30 | - run: python3 maker.py --frontend=mkdocs --api-key=${{ secrets.SEAFILE_API_KEY }} 31 | - run: cd output && mkdocs gh-deploy --force 32 | 33 | - run: bash scripts/export.sh 34 | env: 35 | SEAFILE_ACCOUNT_TOKEN: ${{ secrets.SEAFILE_ACCOUNT_TOKEN }} 36 | - name: 'Upload Artifact' 37 | uses: actions/upload-artifact@v4 38 | with: 39 | name: database-backup 40 | path: | 41 | feiyue.dtable 42 | .cache/* 43 | -------------------------------------------------------------------------------- /.github/workflows/report-issues.yml: -------------------------------------------------------------------------------- 1 | name: report-issues 2 | on: 3 | schedule: 4 | - cron: '0 0 * * *' # every day at 00:00 UTC 5 | workflow_dispatch: 6 | 7 | jobs: 8 | check: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v2 12 | - uses: actions/setup-python@v2 13 | with: 14 | python-version: 3.x 15 | - run: pip3 install requests 16 | - id: report 17 | run: python3 scripts/report_issues.py --api-key=${{ secrets.SEAFILE_API_KEY }} 18 | - name: Check file existence 19 | id: check_files 20 | uses: andstor/file-existence-action@v1 21 | with: 22 | files: "output/issues.log" 23 | - id: cat 24 | if: steps.check_files.outputs.files_exists == 'true' 25 | run: | 26 | { 27 | echo 'ISSUES<> "$GITHUB_ENV" 31 | - name: create an issue 32 | uses: dacbd/create-issue-action@main 33 | if: steps.check_files.outputs.files_exists == 'true' 34 | with: 35 | token: ${{ github.token }} 36 | title: Issues found in the database 37 | body: ${{ env.ISSUES }} 38 | labels: 'database' 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | output/ 2 | 3 | # Byte-compiled / optimized / DLL files 4 | __pycache__/ 5 | *.py[cod] 6 | *$py.class 7 | 8 | # C extensions 9 | *.so 10 | 11 | # Distribution / packaging 12 | .Python 13 | build/ 14 | develop-eggs/ 15 | dist/ 16 | downloads/ 17 | eggs/ 18 | .eggs/ 19 | lib/ 20 | lib64/ 21 | parts/ 22 | sdist/ 23 | var/ 24 | wheels/ 25 | share/python-wheels/ 26 | *.egg-info/ 27 | .installed.cfg 28 | *.egg 29 | MANIFEST 30 | 31 | # PyInstaller 32 | # Usually these files are written by a python script from a template 33 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 34 | *.manifest 35 | *.spec 36 | 37 | # Installer logs 38 | pip-log.txt 39 | pip-delete-this-directory.txt 40 | 41 | # Unit test / coverage reports 42 | htmlcov/ 43 | .tox/ 44 | .nox/ 45 | .coverage 46 | .coverage.* 47 | .cache 48 | nosetests.xml 49 | coverage.xml 50 | *.cover 51 | *.py,cover 52 | .hypothesis/ 53 | .pytest_cache/ 54 | cover/ 55 | 56 | # Translations 57 | *.mo 58 | *.pot 59 | 60 | # Django stuff: 61 | *.log 62 | local_settings.py 63 | db.sqlite3 64 | db.sqlite3-journal 65 | 66 | # Flask stuff: 67 | instance/ 68 | .webassets-cache 69 | 70 | # Scrapy stuff: 71 | .scrapy 72 | 73 | # Sphinx documentation 74 | docs/_build/ 75 | 76 | # PyBuilder 77 | .pybuilder/ 78 | target/ 79 | 80 | # Jupyter Notebook 81 | .ipynb_checkpoints 82 | 83 | # IPython 84 | profile_default/ 85 | ipython_config.py 86 | 87 | # pyenv 88 | # For a library or package, you might want to ignore these files since the code is 89 | # intended to run in multiple environments; otherwise, check them in: 90 | # .python-version 91 | 92 | # pipenv 93 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 94 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 95 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 96 | # install all needed dependencies. 97 | #Pipfile.lock 98 | 99 | # poetry 100 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 101 | # This is especially recommended for binary packages to ensure reproducibility, and is more 102 | # commonly ignored for libraries. 103 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 104 | #poetry.lock 105 | 106 | # pdm 107 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 108 | #pdm.lock 109 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 110 | # in version control. 111 | # https://pdm.fming.dev/#use-with-ide 112 | .pdm.toml 113 | 114 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 115 | __pypackages__/ 116 | 117 | # Celery stuff 118 | celerybeat-schedule 119 | celerybeat.pid 120 | 121 | # SageMath parsed files 122 | *.sage.py 123 | 124 | # Environments 125 | .env 126 | .venv 127 | env/ 128 | venv/ 129 | ENV/ 130 | env.bak/ 131 | venv.bak/ 132 | 133 | # Spyder project settings 134 | .spyderproject 135 | .spyproject 136 | 137 | # Rope project settings 138 | .ropeproject 139 | 140 | # mkdocs documentation 141 | /site 142 | 143 | # mypy 144 | .mypy_cache/ 145 | .dmypy.json 146 | dmypy.json 147 | 148 | # Pyre type checker 149 | .pyre/ 150 | 151 | # pytype static type analyzer 152 | .pytype/ 153 | 154 | # Cython debug symbols 155 | cython_debug/ 156 | 157 | # PyCharm 158 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 159 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 160 | # and can be added to the global gitignore or merged into this file. For a more nuclear 161 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 162 | #.idea/ 163 | 164 | .DS_Store 165 | .vscode -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Liang Yesheng 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 清华大学飞跃数据库 2 | 3 | 清华大学飞跃数据库是一个收集并展示清华大学出国申请案例的数据库,旨在帮助同学们更好地了解往届同学的申请情况,为自己的申请提供参考。 4 | 5 | 数据库中的信息储存于 [SeaTable](https://cloud.seatable.io/dtable/external-links/custom/thu-feiyue/) 中,通过 API 读取并生成网页或 PDF——这使得对数据进行分类、分析成为可能。 6 | 7 | [网页](https://database.feiyue.online)使用 [Material for MkDocs](https://squidfunk.github.io/mkdocs-material/) 生成,每 6 小时自动更新一次,并每周使用 Internet Archive 的 Wayback Machine 对文档进行快照。具体细节详见 [Actions 页面](https://github.com/THU-feiyue/database/actions/)。 8 | 9 | PDF 由 XeLaTeX 编译 LaTeX 文件生成。我们将在每年的申请季开始前在 [Release 页面](https://github.com/THU-feiyue/database/release)发布 PDF 版本。 10 | 11 | ## 构建文档 12 | 13 | ### 安装依赖 14 | 15 | ```bash 16 | pip3 install -r requirements.txt 17 | ``` 18 | 19 | 如果构建为 LaTeX 项目,还需要安装 TeX Live(或使用 Docker)。 20 | 21 | ### 构建 22 | 23 | 目前支持构建为 MkDocs 网页或 LaTeX 文档(PDF)。访问 API 需要有 SeaTable 的 API Key,目前只有管理员具有访问权限。如果没有 API Key,请参考下文。 24 | 25 | 使用如下命令构建: 26 | 27 | ```bash 28 | python3 maker.py --api-key= --frontend={mkdocs|latex} [--link-resources] [--cached] 29 | ``` 30 | 31 | - 使用 `--link-resources` 时,复制静态文档到输出文件夹时将直接创建符号链接,而不是复制文件,这样可以使得 MkDocs 检测到文件的更新,适合在本地开发时打开。 32 | - 使用 `--cached` 时,将会缓存 SeaTable 数据库的数据,而无需使用 API 查询数据库。 33 | 34 | 如果没有 API Key,可以到 [`publish`](https://github.com/THU-feiyue/database/actions/workflows/publish.yml) Action 中最新的 run 处下载名为 `database-backup` 的 artifact,解压后将 `.cache` 目录复制到项目根目录下,并使用 `--cached` 参数即可。 35 | 36 | ### 预览/编译 37 | 38 | #### MkDocs 39 | 40 | 构建完成后,MkDocs 项目将会被输出到 `output` 目录下。在 `output` 目录使用如下命令启动预览服务器: 41 | 42 | ```bash 43 | mkdocs serve 44 | ``` 45 | 46 | #### LaTeX 47 | 48 | 构建完成后,LaTeX 文件将会被输出到 `output/latex` 目录下。在 `output/latex` 目录使用如下命令编译 PDF: 49 | 50 | ```bash 51 | latexmk -xelatex -file-line-error -shell-escape -halt-on-error -interaction=nonstopmode main.tex 52 | ``` 53 | 54 | 也可使用 Docker 编译: 55 | 56 | ```bash 57 | docker run --rm -v $(pwd):/feiyue -w /feiyue ghcr.io/xu-cheng/texlive-full \ 58 | latexmk -xelatex -file-line-error -shell-escape -halt-on-error -interaction=nonstopmode main.tex 59 | ``` 60 | 61 | ## 项目结构 62 | 63 | ``` 64 | . 65 | ├── feiyue # 项目主要代码 66 | ├── maker.py # 构建脚本 67 | ├── resources # 在构建时被直接复制的文件 68 | ├── scripts # 一些脚本 69 | └── templates # 生成网页的模版 70 | ``` 71 | -------------------------------------------------------------------------------- /feiyue/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/THU-feiyue/database/422a164ef20eb1fa7cebfd3fbe45e77f49daefac/feiyue/__init__.py -------------------------------------------------------------------------------- /feiyue/backend/__init__.py: -------------------------------------------------------------------------------- 1 | import re 2 | from pathlib import Path 3 | from . import api 4 | from collections.abc import Generator 5 | import requests 6 | 7 | 8 | _image_url_pattern = re.compile(r"https://.+?(/images/auto-upload/(.+?\.[a-z|A-Z]+))") 9 | 10 | 11 | def get_all_rows(api_key: str) -> tuple[dict, dict, dict, dict]: 12 | api.init_base_token(api_key) 13 | all_majors = api.get_all_rows("本科专业") 14 | all_applicants = api.get_all_rows("申请人") 15 | all_programs = api.get_all_rows("项目") 16 | all_datapoints = api.get_all_rows("数据点") 17 | _rebuild_relations(all_applicants, all_datapoints) 18 | 19 | return all_applicants, all_datapoints, all_programs, all_majors 20 | 21 | 22 | def _rebuild_relations(applicants: dict, datapoints: dict): 23 | # applicant -> datapoints 24 | for applicant in applicants.values(): 25 | applicant["数据点"] = [] 26 | for id, datapoint in datapoints.items(): 27 | if len(datapoint.get("申请人", [])) > 0: 28 | applicants[datapoint["申请人"][0]]["数据点"].append(id) 29 | 30 | 31 | def term_value(year: int, term: str) -> float: 32 | if year is None: 33 | return 0 34 | 35 | year = int(year) 36 | terms = ["Spring", "Summer", "Fall", "Winter"] 37 | year += (terms.index(term) + 1) / 10 38 | 39 | return year 40 | 41 | 42 | def filter_out_invalid( 43 | applicants: dict, datapoints: dict, programs: dict, majors: dict 44 | ): 45 | has_invalid = True 46 | 47 | # dependency: applicant <-> datapoint <-> program 48 | # applicant <-> major 49 | def _applicant_valid(applicant: dict) -> bool: 50 | global has_invalid 51 | valid = ( 52 | "数据点" in applicant 53 | and len(applicant["数据点"]) > 0 54 | and "专业" in applicant 55 | and len(applicant["专业"]) > 0 56 | ) 57 | if not valid: 58 | return False 59 | has_chosen = False 60 | for datapoint in applicant["数据点"]: 61 | if datapoint not in datapoints: 62 | has_invalid = True 63 | applicant["数据点"].remove(datapoint) 64 | else: 65 | if datapoints[datapoint].get("最终去向"): 66 | has_chosen = True 67 | if not has_chosen: 68 | return False 69 | if applicant["专业"][0] not in majors: 70 | return False 71 | return True 72 | 73 | def _program_valid(program: dict) -> bool: 74 | global has_invalid 75 | valid = ( 76 | "项目" in program 77 | and len(program["项目"]) > 0 78 | and "学校" in program 79 | and len(program["学校"]) > 0 80 | and "数据点" in program 81 | and len(program["数据点"]) > 0 82 | ) 83 | if not valid: 84 | return False 85 | for datapoint in program["数据点"]: 86 | if datapoint not in datapoints: 87 | has_invalid = True 88 | program["数据点"].remove(datapoint) 89 | return True 90 | 91 | def _datapoint_valid(datapoint: dict) -> bool: 92 | global has_invalid 93 | valid = ( 94 | "项目" in datapoint 95 | and len(datapoint["项目"]) > 0 96 | and "学年" in datapoint 97 | and "学期" in datapoint 98 | and len(datapoint["学期"]) > 0 99 | and "申请人" in datapoint 100 | and len(datapoint["申请人"]) > 0 101 | ) 102 | if not valid: 103 | return False 104 | for applicant in datapoint["申请人"]: 105 | if applicant not in applicants: 106 | has_invalid = True 107 | datapoint["申请人"].remove(applicant) 108 | if datapoint["项目"][0] not in programs: 109 | return False 110 | return True 111 | 112 | def _major_valid(major: dict) -> bool: 113 | global has_invalid 114 | valid = ( 115 | "院系" in major 116 | and len(major["院系"]) > 0 117 | and "专业" in major 118 | and len(major["专业"]) > 0 119 | and "申请人" in major 120 | and len(major["申请人"]) > 0 121 | ) 122 | if not valid: 123 | return False 124 | for applicant in major["申请人"]: 125 | if applicant not in applicants: 126 | has_invalid = True 127 | major["申请人"].remove(applicant) 128 | return True 129 | 130 | while has_invalid: 131 | has_invalid = False 132 | applicant_invalid = [] 133 | program_invalid = [] 134 | datapoint_invalid = [] 135 | major_invalid = [] 136 | 137 | # applicant 138 | for applicant in applicants.values(): 139 | if not _applicant_valid(applicant): 140 | applicant_invalid.append(applicant["_id"]) 141 | has_invalid = True 142 | for invalid_applicant in applicant_invalid: 143 | applicants.pop(invalid_applicant) 144 | 145 | for datapoint in datapoints.values(): 146 | if not _datapoint_valid(datapoint): 147 | datapoint_invalid.append(datapoint["_id"]) 148 | has_invalid = True 149 | for invalid_datapoint in datapoint_invalid: 150 | datapoints.pop(invalid_datapoint) 151 | 152 | for major in majors.values(): 153 | if not _major_valid(major): 154 | major_invalid.append(major["_id"]) 155 | has_invalid = True 156 | for invalid_major in major_invalid: 157 | majors.pop(invalid_major) 158 | 159 | # program 160 | for program in programs.values(): 161 | if not _program_valid(program): 162 | program_invalid.append(program["_id"]) 163 | has_invalid = True 164 | for invalid_program in program_invalid: 165 | programs.pop(invalid_program) 166 | 167 | 168 | def set_term(applicants: dict, datapoints: dict, key: str): 169 | for applicant in applicants.values(): 170 | # get max term 171 | applicant[key] = max( 172 | [ 173 | (datapoints[dp]["学年"], datapoints[dp]["学期"]) 174 | for dp in applicant["数据点"] 175 | ], 176 | key=lambda x: term_value(x[0], x[1]), 177 | ) 178 | 179 | 180 | def update_nickname(applicants: dict): 181 | for applicant in applicants.values(): 182 | if "姓名/昵称" not in applicant: 183 | new_nickname = "申请人" + str(int(applicant["ID"].split("-")[1])) 184 | applicant["姓名/昵称"] = new_nickname 185 | 186 | 187 | def update_image_path(applicants: dict, base_path: str) -> list[tuple[str, str]]: 188 | ret = [] 189 | 190 | def _sub(m): 191 | ret.append((m.group(2), m.group(1))) 192 | return f"{base_path}/{m.group(2)}" 193 | 194 | for applicant in applicants.values(): 195 | summary = applicant.get("申请总结", None) 196 | if summary is None: 197 | continue 198 | 199 | # replace url with local path 200 | summary = _image_url_pattern.sub(_sub, summary) 201 | applicant["申请总结"] = summary 202 | 203 | return ret 204 | 205 | 206 | def download_image(path: str, api_key: str) -> bytes: 207 | return requests.get(api.get_image_direct_url(path, api_key)).content 208 | -------------------------------------------------------------------------------- /feiyue/backend/api.py: -------------------------------------------------------------------------------- 1 | import requests 2 | 3 | _api_key = None 4 | api_base = None 5 | base_token = None 6 | dtable_uuid = None 7 | 8 | 9 | def seatable_request(method: str, path: str, params: dict = None, data: dict = None): 10 | # make request 11 | response = requests.request( 12 | method, 13 | f"{api_base}/dtable-server/api/v1/dtables/{dtable_uuid}{path}", 14 | params=params, 15 | data=data, 16 | headers={"Accept": "application/json", "Authorization": "Bearer " + base_token}, 17 | ) 18 | 19 | # check response 20 | if response.status_code != 200: 21 | raise Exception( 22 | f"Request failed with status {response.status_code} and message {response.text}" 23 | ) 24 | 25 | return response.json() 26 | 27 | 28 | def init_base_token(api_key: str): 29 | global _api_key 30 | _api_key = api_key 31 | response = requests.request( 32 | "GET", 33 | f"{api_base}/api/v2.1/dtable/app-access-token/", 34 | headers={"Accept": "application/json", "Authorization": "Bearer " + api_key}, 35 | ) 36 | 37 | if response.status_code != 200: 38 | raise Exception( 39 | f"Request failed with status {response.status_code} and message {response.text}" 40 | ) 41 | 42 | response = response.json() 43 | global base_token, dtable_uuid 44 | base_token = response["access_token"] 45 | dtable_uuid = response["dtable_uuid"] 46 | 47 | 48 | def get_all_rows(table_name: str): 49 | ret = {} 50 | query_start = 0 51 | BATCH_SIZE = 100 52 | while True: 53 | response = seatable_request( 54 | "GET", 55 | "/rows", 56 | {"table_name": table_name, "start": query_start, "limit": BATCH_SIZE}, 57 | ) 58 | 59 | for row in response["rows"]: 60 | ret[row["_id"]] = row 61 | if len(response["rows"]) < BATCH_SIZE: 62 | break 63 | query_start += BATCH_SIZE 64 | 65 | return ret 66 | 67 | 68 | def get_image_direct_url(file_name: str, api_key: str) -> str: 69 | response = requests.request( 70 | "GET", 71 | f"{api_base}/api/v2.1/dtable/app-download-link", 72 | params={"path": f"{file_name}"}, 73 | headers={"Accept": "application/json", "Authorization": "Bearer " + api_key}, 74 | ) 75 | return response.json()["download_link"] 76 | -------------------------------------------------------------------------------- /feiyue/frontend/__init__.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import shutil 4 | from pathlib import Path 5 | import statistics 6 | 7 | from ..backend import term_value 8 | 9 | 10 | class Frontend: 11 | def __init__(self, output_dir, template_dir, resource_dir): 12 | self.output_dir = output_dir 13 | self.template_dir = template_dir 14 | self.resource_dir = resource_dir 15 | 16 | def pre_build(self): 17 | pass 18 | 19 | def build(self, applicants, datapoints, programs, majors): 20 | pass 21 | 22 | def copy_resources(self, link): 23 | with open(self.resource_dir / "manifest.json", "r") as f: 24 | manifest: dict = json.load(f) 25 | 26 | for src, dest in manifest["mappings"].items(): 27 | if link: 28 | if os.path.islink(self.output_dir / dest): 29 | os.remove(self.output_dir / dest) 30 | os.symlink(self.resource_dir / src, self.output_dir / dest) 31 | elif os.path.isfile(self.resource_dir / src): 32 | shutil.copy(self.resource_dir / src, self.output_dir / dest) 33 | elif os.path.isdir(self.resource_dir / src): 34 | shutil.copytree( 35 | self.resource_dir / src, 36 | self.output_dir / dest, 37 | dirs_exist_ok=True, 38 | ) 39 | else: 40 | raise Exception(f"Resource {src} not exist") 41 | 42 | def copy_images(self, image_dir: Path): 43 | raise NotImplementedError 44 | 45 | def _preprocess(self, all_applicants, all_datapoints, all_programs, all_majors): 46 | self._set_applicants_by_term(all_datapoints, all_applicants) 47 | self.all_areas = self._get_areas(all_applicants) 48 | # get top programs & terms & GPA median & total programs for each major 49 | # get final destination for each applicant 50 | for major in all_majors.values(): 51 | major["__applicants_by_term"] = [ 52 | ( 53 | term, 54 | [ 55 | applicant 56 | for applicant in applicants 57 | if all_applicants[applicant]["专业"][0] == major["_id"] 58 | ], 59 | ) 60 | for term, applicants in self.applicants_by_term 61 | ] 62 | 63 | major["__programs"] = {} 64 | major["__program_count"] = 0 65 | gpas = [] 66 | for applicant in major.get("申请人", []): 67 | applicant = all_applicants[applicant] 68 | for datapoint in applicant.get("数据点", []): 69 | datapoint = all_datapoints[datapoint] 70 | if datapoint["项目"][0] not in major["__programs"]: 71 | major["__programs"][datapoint["项目"][0]] = 0 72 | major["__programs"][datapoint["项目"][0]] += 1 73 | major["__program_count"] += 1 74 | 75 | if "最终去向" in datapoint and datapoint["最终去向"]: 76 | applicant["__destination"] = datapoint["项目"][0] 77 | 78 | if applicant.get("GPA") is not None: 79 | gpas.append(applicant["GPA"]) 80 | 81 | major["__programs"] = sorted( 82 | list(major["__programs"].items()), key=lambda x: x[1], reverse=True 83 | ) 84 | major["__gpa_median"] = ( 85 | round(statistics.median(gpas), 2) if len(gpas) > 0 else None 86 | ) 87 | 88 | # get terms for each program 89 | for program in all_programs.values(): 90 | program["__applicants_by_term"] = [ 91 | ( 92 | term, 93 | [ 94 | applicant 95 | for applicant in applicants 96 | if any( 97 | all_datapoints[datapoint]["项目"][0] == program["_id"] 98 | for datapoint in all_applicants[applicant]["数据点"] 99 | ) 100 | ], 101 | ) 102 | for term, applicants in self.applicants_by_term 103 | ] 104 | 105 | def _set_applicants_by_term(self, datapoints: dict, applicants: dict) -> dict: 106 | self.applicants_by_term = {} 107 | 108 | for datapoint in datapoints.values(): 109 | if datapoint["学年"] is None: 110 | continue 111 | self.applicants_by_term.setdefault( 112 | (datapoint["学年"], datapoint["学期"]), set() 113 | ).add((datapoint["申请人"][0])) 114 | 115 | self.applicants_by_term = sorted( 116 | [ 117 | (term, sorted(term_applicants, key=lambda x: applicants[x]["专业"])) 118 | for term, term_applicants in self.applicants_by_term.items() 119 | ], 120 | key=lambda x: term_value(*x[0]), 121 | reverse=True, 122 | ) 123 | 124 | def _get_areas(self, all_applicants: dict) -> dict: 125 | all_areas: dict[str, list] = {} 126 | for term, applicants in self.applicants_by_term: 127 | for applicant in applicants: 128 | applicant = all_applicants[applicant] 129 | areas = applicant["申请方向"] 130 | if not areas: 131 | continue 132 | for area in areas: 133 | all_areas.setdefault(area, []).append((term, applicant["_id"])) 134 | 135 | all_areas = dict(sorted(all_areas.items(), key=lambda x: x[0])) 136 | return all_areas 137 | -------------------------------------------------------------------------------- /feiyue/frontend/latex.py: -------------------------------------------------------------------------------- 1 | from . import Frontend 2 | from jinja2 import Environment, FileSystemLoader 3 | from pathlib import Path 4 | import shutil 5 | from datetime import timezone, datetime, timedelta 6 | import re 7 | 8 | 9 | class LatexFrontend(Frontend): 10 | """ 11 | Frontend for generating LaTeX files. 12 | 13 | Files to be generated: 14 | - main.tex 15 | - all_areas.tex 16 | - applicant/ 17 | - .tex 18 | - major/ 19 | - .tex 20 | - program/ 21 | - .tex 22 | """ 23 | 24 | def __init__(self, output_dir: Path, template_dir: Path, resource_dir: Path): 25 | super().__init__(output_dir, template_dir, resource_dir) 26 | self.docs_dir = output_dir / "latex" 27 | 28 | def pre_build(self): 29 | def latex_escape(x) -> str: 30 | s = str(x) 31 | s = s.replace("\\", "\\textbackslash{}") 32 | s = s.replace("{", "\\{") 33 | s = s.replace("}", "\\}") 34 | s = s.replace("$", "\\$") 35 | s = s.replace("&", "\\&") 36 | s = s.replace("#", "\\#") 37 | s = s.replace("^", "\\textasciicircum{}") 38 | s = s.replace("_", "\\_") 39 | s = s.replace("~", "\\textasciitilde{}") 40 | s = s.replace("%", "\\%") 41 | 42 | return s 43 | 44 | list_re = re.compile(r"^( +)(\*|-|\+)", flags=re.MULTILINE) 45 | 46 | def multiply_list_spaces(s: str) -> str: 47 | """ 48 | Multiply the number of spaces of list items by 2. 49 | 50 | SeaTable's editor uses 2 spaces for indentation, but `markdown` package 51 | in LaTeX only supports 4. (https://github.com/Witiko/markdown/issues/55) 52 | """ 53 | return list_re.sub(lambda x: " " * (len(x.group(1)) * 2) + x.group(2), s) 54 | 55 | env = Environment(loader=FileSystemLoader(self.template_dir)) 56 | env.filters["escape"] = latex_escape 57 | env.filters["fix_list"] = multiply_list_spaces 58 | self.applicant_template = env.get_template("applicant.jinja") 59 | self.major_template = env.get_template("major.jinja") 60 | self.program_template = env.get_template("program.jinja") 61 | self.area_template = env.get_template("all_areas.jinja") 62 | self.main_template = env.get_template("main.jinja") 63 | 64 | def build(self, all_applicants, all_datapoints, all_programs, all_majors): 65 | self._preprocess(all_applicants, all_datapoints, all_programs, all_majors) 66 | 67 | self.docs_dir.mkdir(exist_ok=True, parents=True) 68 | 69 | self._build_applicant_pages( 70 | all_applicants, all_datapoints, all_programs, all_majors 71 | ) 72 | 73 | self._build_major_pages( 74 | all_applicants, all_datapoints, all_programs, all_majors 75 | ) 76 | 77 | self._build_program_pages( 78 | all_applicants, all_datapoints, all_programs, all_majors 79 | ) 80 | 81 | self._build_area_page(all_applicants, all_datapoints, all_programs, all_majors) 82 | 83 | self._build_main_page(all_applicants, all_datapoints, all_programs, all_majors) 84 | 85 | def _build_applicant_pages( 86 | self, all_applicants, all_datapoints, all_programs, all_majors 87 | ): 88 | for applicant in all_applicants.values(): 89 | applicant_tex = self.applicant_template.render( 90 | applicant=applicant, 91 | majors=all_majors, 92 | programs=all_programs, 93 | datapoints=all_datapoints, 94 | ) 95 | 96 | output_path = self.docs_dir / "applicant" / f"{applicant['ID']}.tex" 97 | output_path.parent.mkdir(exist_ok=True) 98 | with open(output_path, "w") as f: 99 | f.write(applicant_tex) 100 | 101 | def _build_major_pages( 102 | self, all_applicants, all_datapoints, all_programs, all_majors 103 | ): 104 | for major in all_majors.values(): 105 | major_tex = self.major_template.render( 106 | major=major, 107 | applicants=all_applicants, 108 | datapoints=all_datapoints, 109 | programs=all_programs, 110 | ) 111 | 112 | output_path = self.docs_dir / "major" / f"{major['ID']}.tex" 113 | output_path.parent.mkdir(exist_ok=True) 114 | with open(output_path, "w") as f: 115 | f.write(major_tex) 116 | 117 | def _build_program_pages( 118 | self, all_applicants, all_datapoints, all_programs, all_majors 119 | ): 120 | for program in all_programs.values(): 121 | # do this work outside of jinja2 -- it's too complicated 122 | program_datapoints = all_datapoints.copy() 123 | program_datapoints = [ 124 | datapoint 125 | for datapoint in all_datapoints.values() 126 | if datapoint["项目"][0] == program["_id"] 127 | ] 128 | for datapoint in program_datapoints: 129 | datapoint["申请人"] = datapoint["申请人"][0] 130 | 131 | program_tex = self.program_template.render( 132 | program=program, 133 | majors=all_majors, 134 | applicants=all_applicants, 135 | datapoints=all_datapoints, 136 | program_datapoints=program_datapoints, 137 | ) 138 | 139 | output_path = self.docs_dir / "program" / f"{program['ID']}.tex" 140 | output_path.parent.mkdir(exist_ok=True) 141 | with open(output_path, "w") as f: 142 | f.write(program_tex) 143 | 144 | def _build_area_page( 145 | self, all_applicants, all_datapoints, all_programs, all_majors 146 | ): 147 | area_tex = self.area_template.render( 148 | all_areas=self.all_areas, 149 | applicants=all_applicants, 150 | majors=all_majors, 151 | programs=all_programs, 152 | datapoints=all_datapoints, 153 | ) 154 | with open(self.docs_dir / "all_areas.tex", "w") as f: 155 | f.write(area_tex) 156 | 157 | def _build_main_page( 158 | self, all_applicants, all_datapoints, all_programs, all_majors 159 | ): 160 | sorted_majors = sorted( 161 | list(all_majors.values()), 162 | key=lambda x: (x["院系"], x["ID"]), 163 | ) 164 | 165 | sorted_programs = sorted( 166 | list(all_programs.values()), 167 | key=lambda x: (len(x["数据点"]), x["ID"]), 168 | reverse=True, 169 | ) 170 | 171 | main_latex = self.main_template.render( 172 | applicants_by_term=self.applicants_by_term, 173 | applicants=all_applicants, 174 | programs=sorted_programs, 175 | majors=sorted_majors, 176 | build_date=datetime.now(tz=timezone(timedelta(hours=+8))).strftime( 177 | "%Y年%-m月%-d日" 178 | ), 179 | ) 180 | with open(self.docs_dir / "main.tex", "w") as f: 181 | f.write(main_latex) 182 | 183 | def copy_images(self, image_dir: Path): 184 | shutil.copytree(image_dir, self.docs_dir / "images", dirs_exist_ok=True) 185 | -------------------------------------------------------------------------------- /feiyue/frontend/mkdocs.py: -------------------------------------------------------------------------------- 1 | from . import Frontend 2 | from jinja2 import Environment, FileSystemLoader 3 | from pathlib import Path 4 | from datetime import timezone, datetime, timedelta 5 | import shutil 6 | 7 | 8 | class MkDocsFrontend(Frontend): 9 | """ 10 | Frontend for generating MkDocs files. 11 | 12 | File to be generated: 13 | - docs/ 14 | - index.md 15 | - area.md 16 | - applicant/ 17 | - index.md 18 | - .md 19 | - major/ 20 | - index.md 21 | - .md 22 | - program/ 23 | - index.md 24 | - .md 25 | - mkdocs.yml 26 | """ 27 | 28 | def __init__(self, output_dir, template_dir, resource_dir): 29 | super().__init__(output_dir, template_dir, resource_dir) 30 | self.mkdocs_docs_dir = output_dir / "docs" 31 | 32 | def pre_build(self): 33 | env = Environment(loader=FileSystemLoader(self.template_dir)) 34 | self.applicant_template = env.get_template("applicant.jinja") 35 | self.major_template = env.get_template("major.jinja") 36 | self.program_template = env.get_template("program.jinja") 37 | self.mkdocs_template = env.get_template("mkdocs_config.jinja") 38 | self.index_template = env.get_template("index.jinja") 39 | self.applicant_index_template = env.get_template("applicant_index.jinja") 40 | self.major_index_template = env.get_template("major_index.jinja") 41 | self.program_index_template = env.get_template("program_index.jinja") 42 | self.area_index_template = env.get_template("area_index.jinja") 43 | 44 | def build(self, all_applicants, all_datapoints, all_programs, all_majors): 45 | self._preprocess(all_applicants, all_datapoints, all_programs, all_majors) 46 | 47 | output_dir = Path(self.output_dir) 48 | output_dir.mkdir(exist_ok=True) 49 | mkdocs_docs_dir = output_dir / "docs" 50 | mkdocs_docs_dir.mkdir(exist_ok=True) 51 | 52 | self._build_applicant_pages( 53 | all_applicants, all_datapoints, all_programs, all_majors 54 | ) 55 | 56 | self._build_major_pages( 57 | all_applicants, all_datapoints, all_programs, all_majors 58 | ) 59 | 60 | self._build_program_pages( 61 | all_applicants, all_datapoints, all_programs, all_majors 62 | ) 63 | 64 | self._build_index_pages( 65 | all_applicants, 66 | all_datapoints, 67 | all_programs, 68 | all_majors, 69 | ) 70 | 71 | def _build_applicant_pages( 72 | self, all_applicants, all_datapoints, all_programs, all_majors 73 | ): 74 | for applicant in all_applicants.values(): 75 | applicant_md = self.applicant_template.render( 76 | metadata={}, 77 | applicant=applicant, 78 | majors=all_majors, 79 | programs=all_programs, 80 | datapoints=all_datapoints, 81 | ) 82 | 83 | output_path = self.mkdocs_docs_dir / "applicant" / f"{applicant['ID']}.md" 84 | output_path.parent.mkdir(exist_ok=True) 85 | with open(output_path, "w") as f: 86 | f.write(applicant_md) 87 | 88 | def _build_major_pages( 89 | self, all_applicants, all_datapoints, all_programs, all_majors 90 | ): 91 | for major in all_majors.values(): 92 | major_md = self.major_template.render( 93 | metadata={}, 94 | major=major, 95 | applicants=all_applicants, 96 | programs=all_programs, 97 | datapoints=all_datapoints, 98 | ) 99 | 100 | output_path = self.mkdocs_docs_dir / "major" / f"{major['ID']}.md" 101 | output_path.parent.mkdir(exist_ok=True) 102 | with open(output_path, "w") as f: 103 | f.write(major_md) 104 | 105 | def _build_program_pages( 106 | self, all_applicants, all_datapoints, all_programs, all_majors 107 | ): 108 | for program in all_programs.values(): 109 | # do this work outside of jinja2 -- it's too complicated 110 | program_datapoints = all_datapoints.copy() 111 | program_datapoints = [ 112 | datapoint 113 | for datapoint in all_datapoints.values() 114 | if datapoint["项目"][0] == program["_id"] 115 | ] 116 | for datapoint in program_datapoints: 117 | datapoint["申请人"] = datapoint["申请人"][0] 118 | 119 | program_md = self.program_template.render( 120 | metadata={}, 121 | program=program, 122 | majors=all_majors, 123 | applicants=all_applicants, 124 | program_datapoints=program_datapoints, 125 | ) 126 | 127 | output_path = self.mkdocs_docs_dir / "program" / f"{program['ID']}.md" 128 | output_path.parent.mkdir(exist_ok=True) 129 | with open(output_path, "w") as f: 130 | f.write(program_md) 131 | 132 | def _build_index_pages( 133 | self, all_applicants, all_datapoints, all_programs, all_majors 134 | ): 135 | sorted_majors = sorted( 136 | list(all_majors.values()), 137 | key=lambda x: len(x["申请人"]), 138 | reverse=True, 139 | ) 140 | 141 | sorted_programs = sorted( 142 | list(all_programs.values()), 143 | key=lambda x: len(x["数据点"]), 144 | reverse=True, 145 | ) 146 | 147 | mkdocs_config = self.mkdocs_template.render( 148 | all_applicants=all_applicants, 149 | all_majors=all_majors, 150 | all_programs=all_programs, 151 | applicants_by_term=self.applicants_by_term, 152 | majors=sorted_majors, 153 | programs=sorted_programs, 154 | build_time=datetime.now(tz=timezone(timedelta(hours=+8))).strftime( 155 | "%Y年%-m月%-d日 %H:%M" 156 | ), 157 | ) 158 | with open(self.output_dir / "mkdocs.yml", "w") as f: 159 | f.write(mkdocs_config) 160 | 161 | index_md = self.index_template.render( 162 | applicant_num=len(all_applicants), 163 | major_num=len(all_majors), 164 | program_num=len(all_programs), 165 | area_num=len(self.all_areas), 166 | ) 167 | with open(self.mkdocs_docs_dir / "index.md", "w") as f: 168 | f.write(index_md) 169 | 170 | applicant_index_md = self.applicant_index_template.render( 171 | applicants_by_term=self.applicants_by_term, 172 | applicants=all_applicants, 173 | majors=all_majors, 174 | programs=all_programs, 175 | datapoints=all_datapoints, 176 | ) 177 | with open(self.mkdocs_docs_dir / "applicant" / "index.md", "w") as f: 178 | f.write(applicant_index_md) 179 | 180 | major_index_md = self.major_index_template.render( 181 | majors=sorted_majors, 182 | ) 183 | with open(self.mkdocs_docs_dir / "major" / "index.md", "w") as f: 184 | f.write(major_index_md) 185 | 186 | program_index_md = self.program_index_template.render( 187 | programs=sorted_programs, 188 | ) 189 | with open(self.mkdocs_docs_dir / "program" / "index.md", "w") as f: 190 | f.write(program_index_md) 191 | 192 | # area index page 193 | area_index_md = self.area_index_template.render( 194 | all_areas=self.all_areas, 195 | applicants=all_applicants, 196 | majors=all_majors, 197 | programs=all_programs, 198 | datapoints=all_datapoints, 199 | ) 200 | with open(self.mkdocs_docs_dir / "area.md", "w") as f: 201 | f.write(area_index_md) 202 | 203 | def copy_images(self, image_dir: Path): 204 | shutil.copytree(image_dir, self.mkdocs_docs_dir / "images", dirs_exist_ok=True) 205 | -------------------------------------------------------------------------------- /maker.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import json 3 | import os 4 | import shutil 5 | from pathlib import Path 6 | 7 | import feiyue.backend as backend 8 | from feiyue.frontend.mkdocs import MkDocsFrontend 9 | from feiyue.frontend.latex import LatexFrontend 10 | 11 | file_path = Path(os.path.dirname(os.path.realpath(__file__))) 12 | 13 | if __name__ == "__main__": 14 | parser = argparse.ArgumentParser() 15 | parser.add_argument("--api-key", type=str, default=None) 16 | parser.add_argument("--api-base", type=str, default="https://cloud.seatable.io") 17 | parser.add_argument("--output-dir", type=str, default="output") 18 | parser.add_argument( 19 | "--link-resources", 20 | action="store_true", 21 | help="create symlinks instead of copying resources", 22 | ) 23 | parser.add_argument( 24 | "--cached", 25 | action="store_true", 26 | help="use data cached on device without querying the API", 27 | ) 28 | parser.add_argument("--frontend", type=str, required=True, help="mkdocs or latex") 29 | args = parser.parse_args() 30 | 31 | api_key = args.api_key 32 | backend.api.api_base = args.api_base 33 | 34 | cache_loaded = False 35 | cache_dir = file_path / ".cache" 36 | 37 | if args.cached: 38 | 39 | def load_cache(file_name: Path): 40 | with open(cache_dir / file_name, "r") as f: 41 | return json.load(f) 42 | 43 | # check if is cached already 44 | if os.path.isdir(cache_dir): 45 | print("Loading rows from cache...") 46 | try: 47 | all_applicants = load_cache("applicants.json") 48 | all_datapoints = load_cache("datapoints.json") 49 | all_programs = load_cache("programs.json") 50 | all_majors = load_cache("majors.json") 51 | cache_loaded = True 52 | except: 53 | shutil.rmtree(file_path / ".cache") 54 | 55 | if not cache_loaded: 56 | if api_key is None: 57 | raise Exception("API key is not provided") 58 | print("Getting all rows...") 59 | all_applicants, all_datapoints, all_programs, all_majors = backend.get_all_rows( 60 | api_key 61 | ) 62 | 63 | # create cache 64 | cache_dir.mkdir(exist_ok=True) 65 | with open(cache_dir / "applicants.json", "w") as f: 66 | json.dump(all_applicants, f, ensure_ascii=False) 67 | with open(cache_dir / "datapoints.json", "w") as f: 68 | json.dump(all_datapoints, f, ensure_ascii=False) 69 | with open(cache_dir / "programs.json", "w") as f: 70 | json.dump(all_programs, f, ensure_ascii=False) 71 | with open(cache_dir / "majors.json", "w") as f: 72 | json.dump(all_majors, f, ensure_ascii=False) 73 | 74 | # download uploaded images from seatable 75 | if api_key is not None: 76 | image_cache_dir = cache_dir / "images" 77 | image_cache_dir.mkdir(exist_ok=True) 78 | print("Downloading images...") 79 | 80 | # TODO: more flexible path 81 | paths = backend.update_image_path(all_applicants, "../images") 82 | for file_name, url_path in paths: 83 | path = image_cache_dir / file_name 84 | if path.exists(): 85 | continue 86 | # download 87 | data = backend.download_image(url_path, api_key) 88 | with open(image_cache_dir / file_name, "wb") as f: 89 | f.write(data) 90 | 91 | print( 92 | "Done, got", 93 | len(all_applicants), 94 | "applicants,", 95 | len(all_datapoints), 96 | "datapoints,", 97 | len(all_programs), 98 | "programs,", 99 | len(all_majors), 100 | "majors", 101 | ) 102 | 103 | # filter out invalid datapoints 104 | backend.filter_out_invalid(all_applicants, all_datapoints, all_programs, all_majors) 105 | 106 | # get the terms that each applicant applied for & update nickname 107 | backend.set_term(all_applicants, all_datapoints, key="__term") 108 | backend.update_nickname(all_applicants) 109 | 110 | # build 111 | if args.frontend == "mkdocs": 112 | frontend = MkDocsFrontend( 113 | file_path / args.output_dir, 114 | file_path / "templates" / "mkdocs", 115 | file_path / "resources" / "mkdocs", 116 | ) 117 | elif args.frontend == "latex": 118 | frontend = LatexFrontend( 119 | file_path / args.output_dir, 120 | file_path / "templates" / "latex", 121 | file_path / "resources" / "latex", 122 | ) 123 | else: 124 | raise Exception(f"Invalid frontend {args.frontend}") 125 | 126 | frontend.pre_build() 127 | frontend.build(all_applicants, all_datapoints, all_programs, all_majors) 128 | frontend.copy_resources(args.link_resources) 129 | frontend.copy_images(image_cache_dir) 130 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | jinja2 2 | requests 3 | mkdocs-material 4 | mkdocs-awesome-pages-plugin 5 | mdx_truly_sane_lists -------------------------------------------------------------------------------- /resources/latex/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "mappings": {} 3 | } -------------------------------------------------------------------------------- /resources/mkdocs/docs/CNAME: -------------------------------------------------------------------------------- 1 | database.feiyue.online -------------------------------------------------------------------------------- /resources/mkdocs/docs/contribute.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 贡献说明 3 | comments: true 4 | --- 5 | 6 | 数据库的数据存于 SeaTable 中,为了方便贡献数据,我们设计了两个用于录入信息的问卷/表单,完成后会自动更新到数据库中。**建议使用 Chromium 内核的浏览器(如 Chrome、Edge)访问**,不建议使用 Safari 或 FireFox(经测试,部分功能有 bug)。 7 | 8 | 为了防止滥用行为,表单只有在登录 SeaTable 账号后才可填写。在开始之前,请先[注册](https://cloud.seatable.io/accounts/login/){:target="_blank"}一个 SeaTable 账号。 9 | 10 | 如有任何疑问或建议,可在本页面下方的评论区留言。 11 | 12 | ## 第一步:创建申请人信息 13 | 14 | 首先,您需要访问[「创建申请人」](https://cloud.seatable.io/dtable/forms/b0691605-791c-4504-b07e-6f3c89b4165e/){:target="_blank"}问卷创建一个申请人,并填写个人信息,包括专业、语言成绩、GPA、科研经历等。除了专业以外,所有的信息都是选填的;您填写的信息越完整,参考价值就越大,对其他同学的帮助也就越大。 15 | 16 | 表单内每个问题都有对应的说明,根据说明填写即可。 17 | 18 | 填写完成后,问卷会自动重定向到「添加数据点」表单。 19 | 20 | ## 第二步:添加数据点 21 | 22 | 在创建申请人信息后,您需要访问[「添加数据点」](https://cloud.seatable.io/dtable/collection-tables/2695773c-aa8e-4f14-a95f-e6acd9cf010d/){:target="_blank"}表单填写您所申请的项目(即「数据点」)。详细流程如下: 23 | 24 | 1. **点击「+」添加一条记录。** 25 | 2. **点击「项目」栏对应的单元格,并点击「+」。** 26 | 27 | 先在弹出的框中搜索学校,如已有项目(请留意项目的不同缩写!),点击选择即可。 28 | 29 | 如果没有找到您所申请的项目,则: 30 | 31 | 1. 点击左上角的返回按钮。 32 | 2. 点击右上角的「添加记录」按钮。 33 | 3. 填写项目信息。注意: 34 | - 填写的学校名称应与已有的项目保持一致。 35 | - 对于项目名称: 36 | - 缩写不应引发歧义,如不确定,请填写全名。 37 | - 对于 Master 项目,请加上前缀,如 MSCS、MS Stat、MA Asian Studies。 38 | - 对于 PhD 项目,无需加上前缀,如 CS、Stat、Astro。 39 | - **不需要**在这个界面中添加数据点。 40 | 41 | 3. **在「申请人」栏中点击「+」并选择您所创建的申请人。** 42 | 4. **在「结果」栏中选择您的申请结果。** 43 | 44 | 未出结果或正在 Waiting List 中请选择「Pending」,如最终仍在 Waiting List 中,请选择「Reject」。 45 | 46 | 5. **如果您最终选择了这个项目,请在「最终去向」栏中点击打勾。** 47 | 6. **填上申请的学年以及学期。** 48 | 49 | 重复的信息,比如「申请人」、「学年」和「学期」,可以在填完一个后复制到其他单元格中。 50 | 51 | ## 信息更新/删除 52 | 53 | - 如果您需要修改或删除个人资料,请访问[「修改个人资料」](https://cloud.seatable.io/dtable/collection-tables/304f1ac0-eb9c-4e91-8794-72e98bbbb383/){:target="_blank"}表单,选择对应的行进行编辑。 54 | - 如您需要修改或删除数据点,请访问[「修改数据点」](https://cloud.seatable.io/dtable/collection-tables/2695773c-aa8e-4f14-a95f-e6acd9cf010d/){:target="_blank"}表单,选择对应的行进行编辑。 55 | 56 | 请注意,即使您删除了某些信息,它们在以前的备份中仍然可能存在。 57 | 58 | ## 常见问题 59 | 60 | ### 申请人信息相关 61 | 62 | **我找不到我的专业** 63 | 64 | 如果您的专业不在列表中,请在[问题反馈](./feedback.md)的评论区中以此格式留言: 65 | 66 | ``` 67 | 专业 `X`(院系为 `Y`) 不在列表中,为新添加的专业(或:目前已不开设) 68 | ``` 69 | 70 | 对于新的专业,我们会根据[清华大学本科专业目录](https://www.tsinghua.edu.cn/jyjx/bksjy/bkzy.htm)进行更新。对于以前的专业,我们会视情况与已有专业合并或创建新的专业。 71 | 72 | **我本科不在清华就读,如何选择本科专业?** 73 | 74 | 如果本科非清华毕业,我们无法对您的本科专业进行归类,请选择「N/A - 本科外校」。 75 | 76 | **我找不到我的申请方向** 77 | 78 | 申请方向由我们手动维护,便于进行分类。由于方向并不全面,可能在里面找不到对应的方向,这种情况请选择「未分类」并在下面的「申请方向说明」中用以下格式注明: 79 | 80 | ``` 81 | 【申请方向】XXX 82 | ``` 83 | 84 | 我们会定时将未分类的方向添加进列表中并更新您的信息,您不需要再进行操作。 85 | 86 | **如何将 Markdown 纯文本粘贴到申请总结中?** 87 | 88 | SeaTable 的富文本编辑器不支持粘贴 Markdown 纯文本。可使用[这个工具](https://liang2kl.github.io/markdown-render/){:target="_blank"}将其转换为 HTML 后复制粘贴到表单的富文本编辑器中,也可在 Typora 等 Markdown 编辑器的预览中复制渲染后的文档。 89 | 90 | ### 数据点相关 91 | 92 | **新建项目时填错了信息/发现项目信息有误** 93 | 94 | 出于数据安全考虑,我们无法开放对项目信息的修改/删除权限。如果您填错了信息,或发现了问题,请在[问题反馈](./feedback.md)的评论区中以此格式留言: 95 | 96 | ``` 97 | 项目填写(或:项目信息)错误,项目名称为 `X@Y`,应修改为:`A@B`(或:应删除) 98 | ``` 99 | 100 | -------------------------------------------------------------------------------- /resources/mkdocs/docs/faq.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 常见问题 3 | comments: true 4 | --- 5 | 6 | ## 名词解释 7 | 8 | #### 什么是申请人(申请案例)、数据点、项目? 9 | 10 | 「申请人」(或「申请案例」)指的是某个同学的所有信息。「数据点」指的是某个申请人申请某个项目的结果,属于申请人。「项目」指的是某个项目的信息,所有申请人共享。每个数据点对应一个项目。 11 | 12 | #### 数据点和申请人是什么关系? 13 | 14 | 一个申请人可以有多个数据点,代表申请人申请的多个项目。 15 | 16 | ## 文档更新 17 | 18 | #### 提交了个人信息/数据点,但是没有找到申请案例/看到更新 19 | 20 | 本文档并非实时更新。我们使用 GitHub Actions 自动更新文档,频率为 6 小时一次,在网页左下角可以看到上一次更新的时间。在下一次更新时您的信息将会被更新到文档中。 21 | 22 | #### 文档更新后还是看不到提交/更新的申请案例/数据点 23 | 24 | 为避免无效案例出现在文档中,我们会对案例进行筛选。只要您的个人资料中包括至少一个有效(即信息完整的)数据点,您的申请案例就会被更新到文档中。具体逻辑请参考[相关代码](https://github.com/THU-feiyue/database/blob/main/feiyue/backend/__init__.py)。 25 | 26 | #### 文档有 PDF 版本吗? 27 | 28 | 目前还没有。我们计划在每年申请季开始将前一年的案例制作为 PDF,并发布在 [Release 页面](https://github.com/THU-feiyue/database/releases)上。 29 | 30 | ## 数据公开与安全 31 | 32 | #### 数据是否完全公开?我可以通过什么方式获取? 33 | 34 | 完整的数据库公开在 SeaTable 上。除了直接在 SeaTable 上浏览,您也可以在 SeaTable 页面中右上角点击导出下载完整数据库。我们不限制清华大学以外的同学访问。 35 | 36 | #### 如何保证数据安全? 37 | 38 | 每次使用 GitHub Actions 更新文档时,我们会对完整数据库进行备份,存于 Actions 的 Artifacts 中,您可以在 [Actions 页面](https://github.com/THU-feiyue/database/actions/workflows/publish.yml)中查看。另外,我们也会定期使用 Internet Archive 的 Wayback Machine 对文档进行快照,您可以在[这里](https://web.archive.org/web/*/https://database.feiyue.online/)查看。 39 | 40 | #### 如何防止恶意行为? 41 | 42 | 完整数据库只有公开的只读权限。任何人可以添加申请人信息/数据点,但只能修改当前账号创建的记录。为了方便提交数据,我们无法完全防止恶意行为,但会通过定期清除无效数据解决。 43 | 44 | ## 贡献相关问题 45 | 46 | 请参考[贡献说明](./contribute.md#常见问题)中的说明。 -------------------------------------------------------------------------------- /resources/mkdocs/docs/feedback.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 问题反馈 3 | comments: true 4 | --- 5 | 6 | 如在使用过程中遇到任何问题,或有任何建议,可在下方留言,[新建 Issue](https://github.com/THU-feiyue/database/issues/new/choose),或发送邮件到 [contact@feiyue.online](mailto:contact@feiyue.online)。我们会尽快回复。 7 | -------------------------------------------------------------------------------- /resources/mkdocs/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "mappings": { 3 | "overrides": "overrides", 4 | "docs/faq.md": "docs/faq.md", 5 | "docs/contribute.md": "docs/contribute.md", 6 | "docs/feedback.md": "docs/feedback.md", 7 | "stylesheets": "docs/stylesheets", 8 | "docs/CNAME": "docs/CNAME" 9 | } 10 | } -------------------------------------------------------------------------------- /resources/mkdocs/overrides/main.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block announce %} 4 | 本文档十分欢迎并且非常需要您的贡献!参与→ 5 | {% endblock %} -------------------------------------------------------------------------------- /resources/mkdocs/overrides/partials/comments.html: -------------------------------------------------------------------------------- 1 | {% if page.meta.comments %} 2 |
3 | 8 | 9 | 43 | {% endif %} -------------------------------------------------------------------------------- /resources/mkdocs/overrides/partials/copyright.html: -------------------------------------------------------------------------------- 1 | 22 | 23 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /resources/mkdocs/stylesheets/extra.css: -------------------------------------------------------------------------------- 1 | /* full-width table */ 2 | .md-typeset__table, 3 | .md-typeset__table>table { 4 | min-width: 100% !important; 5 | } 6 | 7 | .md-typeset__table table { 8 | display: table !important; 9 | } 10 | 11 | /* flex container with horizontal line in the middle */ 12 | .lined-flex { 13 | display: flex; 14 | align-items: center; 15 | justify-content: space-between; 16 | gap: 1.5em; 17 | } 18 | 19 | .lined-flex>span:last-child { 20 | flex-shrink: 0; 21 | } 22 | 23 | .lined-flex hr { 24 | flex-grow: 1; 25 | min-width: 2em; 26 | margin: 0 0 0 0; 27 | } 28 | 29 | @media (max-width: 400px) { 30 | .lined-flex hr { 31 | display: none; 32 | } 33 | 34 | .lined-flex { 35 | display: block; 36 | } 37 | } 38 | 39 | /* card */ 40 | .card-container { 41 | display: flex; 42 | gap: 0.5em; 43 | justify-content: space-between; 44 | height: 100%; 45 | flex-direction: column; 46 | } 47 | 48 | .md-typeset .grid.cards-metric>ul>li:hover { 49 | box-shadow: none !important; 50 | border: .05rem solid var(--md-default-fg-color--lightest); 51 | } 52 | 53 | /* application summary */ 54 | 55 | @media (min-width: 800px) { 56 | .summary-inner { 57 | padding: 1.25em 1.25em 1.25em 1.25em; 58 | border: .05rem solid var(--md-typeset-table-color); 59 | border-radius: .1rem; 60 | } 61 | 62 | .summary-inner> :first-child { 63 | margin-top: 0; 64 | padding-top: 0; 65 | } 66 | 67 | .summary-inner> :last-child { 68 | margin-bottom: 0; 69 | padding-bottom: 0; 70 | } 71 | } 72 | 73 | .summary-inner h4 { 74 | text-decoration: underline; 75 | text-decoration-color: var(--md-typeset-table-color); 76 | text-underline-offset: 0.5em; 77 | text-decoration-thickness: .15rem; 78 | } -------------------------------------------------------------------------------- /scripts/archive_site.py: -------------------------------------------------------------------------------- 1 | from lxml import etree 2 | import requests 3 | import argparse 4 | import time 5 | 6 | WAYBACK_URL = "https://web.archive.org/save/" 7 | RETRIES = 5 8 | TIME_LIMIT = 6 * 60 * 59 # 6 hours 9 | 10 | if __name__ == "__main__": 11 | parser = argparse.ArgumentParser(description="Archive a site") 12 | parser.add_argument("site", type=str) 13 | parser.add_argument("--start", type=int, default=0) 14 | parser.add_argument("--end", type=int, default=None) 15 | args = parser.parse_args() 16 | 17 | start_time = time.time() 18 | 19 | r = requests.get(args.site) 20 | sitemap_root = etree.fromstring(r.content) 21 | 22 | sites = [sitemap.getchildren()[0].text for sitemap in sitemap_root] 23 | sites = sites[args.start : args.end] 24 | 25 | print( 26 | f"Archiving {len(sites)} sites of {args.site} (from {args.start} to {args.end})" 27 | ) 28 | 29 | while len(sites) > 0: 30 | if time.time() - start_time > TIME_LIMIT: 31 | print(f"Time limit reached, skipping remaining {len(sites)} sites") 32 | break 33 | site = sites.pop(0) 34 | for i in range(RETRIES + 1): 35 | try: 36 | r = requests.get(WAYBACK_URL + site) 37 | r.raise_for_status() 38 | print(f"Archived {site}") 39 | break 40 | except KeyboardInterrupt: 41 | exit(0) 42 | except BaseException as e: 43 | print(f"Failed to archive {site}, retrying (attempt {i+1}/{RETRIES})") 44 | else: 45 | sites.append(site) 46 | print(f"Failed to archive {site} after {RETRIES} attempts") 47 | -------------------------------------------------------------------------------- /scripts/export.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | wget 'https://cloud.seatable.io/api/v2.1/workspace/46522/synchronous-export/export-dtable/?dtable_name=THU%20feiyue' \ 3 | --header 'accept: application/json' \ 4 | --header "authorization: Bearer $SEAFILE_ACCOUNT_TOKEN" \ 5 | --output-document 'feiyue.dtable' 6 | -------------------------------------------------------------------------------- /scripts/report_issues.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import os 3 | from pathlib import Path 4 | import sys 5 | import collections 6 | 7 | sys.path.append(Path(os.path.dirname(os.path.realpath(__file__))).parent.as_posix()) 8 | import feiyue.backend.api as api 9 | import feiyue.backend as backend 10 | 11 | 12 | def get_duplicate_programs(programs: dict) -> dict[tuple[str, str], list]: 13 | program_by_name = {} 14 | for program in programs.values(): 15 | if not program.get("学校") or not program.get("项目"): 16 | continue 17 | program_by_name.setdefault( 18 | (program["学校"].lower(), program["项目"].lower()), [] 19 | ).append(program) 20 | 21 | return { 22 | name: programs 23 | for name, programs in program_by_name.items() 24 | if len(programs) > 1 25 | } 26 | 27 | 28 | def get_incomplete_programs(programs: dict) -> list[str]: 29 | incomplete_programs = [] 30 | for program in programs.values(): 31 | if not program.get("学校") or not program.get("项目"): 32 | incomplete_programs.append(program["ID"]) 33 | 34 | return incomplete_programs 35 | 36 | 37 | def get_duplicate_datapoints_of_applicants( 38 | applicants: dict, datapoints: dict, programs: dict 39 | ) -> dict[str, list]: 40 | ret = {} 41 | for applicant in applicants.values(): 42 | duplicate_programs = [] 43 | if not applicant.get("数据点", []): 44 | continue 45 | duplicate_programs = [ 46 | program 47 | for program, count in collections.Counter( 48 | [ 49 | programs[datapoints[dp]["项目"][0]]["ID"] 50 | for dp in applicant["数据点"] 51 | if datapoints[dp]["项目"] 52 | ] 53 | ).items() 54 | if count > 1 55 | ] 56 | if duplicate_programs: 57 | ret[applicant["ID"]] = duplicate_programs 58 | 59 | return ret 60 | 61 | 62 | def get_uncategorized_areas(applicants: dict) -> list[str]: 63 | return [ 64 | applicant["ID"] 65 | for applicant in applicants.values() 66 | if "未分类" in (applicant.get("申请方向") or []) 67 | ] 68 | 69 | 70 | if __name__ == "__main__": 71 | parser = argparse.ArgumentParser() 72 | parser.add_argument("--api-key", type=str, default=None) 73 | parser.add_argument("--api-base", type=str, default="https://cloud.seatable.io") 74 | parser.add_argument("--output", type=str, default="output/issues.log") 75 | args = parser.parse_args() 76 | 77 | Path(args.output).parent.mkdir(parents=True, exist_ok=True) 78 | 79 | def log(*_args, **_kwargs): 80 | with open(args.output, "a") as f: 81 | print(*_args, **_kwargs, file=f) 82 | print(*_args, **_kwargs, file=sys.stderr) 83 | 84 | api.api_base = args.api_base 85 | api.init_base_token(args.api_key) 86 | 87 | all_applicants, all_datapoints, all_programs, all_majors = backend.get_all_rows( 88 | args.api_key 89 | ) 90 | 91 | duplicate_programs = get_duplicate_programs(all_programs) 92 | 93 | if len(duplicate_programs) == 0: 94 | print("No duplicate programs found.", file=sys.stderr) 95 | else: 96 | log("**Duplicate programs**\n") 97 | for (school, program_name), programs in duplicate_programs.items(): 98 | log(f" - {program_name}@{school}: {[p['ID'] for p in programs]}") 99 | log("") 100 | 101 | incomplete_programs = get_incomplete_programs(all_programs) 102 | 103 | if len(incomplete_programs) == 0: 104 | print("No incomplete programs found.", file=sys.stderr) 105 | else: 106 | log("**Incomplete programs**\n") 107 | for program_id in incomplete_programs: 108 | log(f" - {program_id}") 109 | log("") 110 | 111 | duplicate_datapoints = get_duplicate_datapoints_of_applicants( 112 | all_applicants, all_datapoints, all_programs 113 | ) 114 | 115 | if len(duplicate_datapoints) == 0: 116 | print("No duplicate datapoints found.", file=sys.stderr) 117 | else: 118 | log("**Duplicate datapoints**\n") 119 | for applicant_id, programs in duplicate_datapoints.items(): 120 | log(f" - {applicant_id}: {programs}") 121 | log("") 122 | 123 | uncategorized_areas = get_uncategorized_areas(all_applicants) 124 | 125 | if len(uncategorized_areas) == 0: 126 | print("No uncategorized areas found.", file=sys.stderr) 127 | else: 128 | log("**Applicants with uncategorized application areas**\n") 129 | for applicant_id in uncategorized_areas: 130 | log(f" - {applicant_id}") 131 | log("") 132 | -------------------------------------------------------------------------------- /templates/latex/all_areas.jinja: -------------------------------------------------------------------------------- 1 | {%- from "macros.jinja" import get_area_tags, get_applicant_link, get_program_link, get_major_link -%} 2 | 3 | {% for area, area_applicants in all_areas.items() %} 4 | \section{ {{ area }} } 5 | \label{area:{{ area }}} 6 | 7 | \begin{tabularx}{\textwidth}{lXlX} 8 | \toprule 9 | \textbf{申请人} & \textbf{专业} & \textbf{学期} & \textbf{去向} \\ 10 | \midrule 11 | \endfirsthead 12 | \multicolumn{4}{l@{}}{(Continued)}\\ 13 | \toprule 14 | \textbf{申请人} & \textbf{专业} & \textbf{学期} & \textbf{去向} \\ 15 | \midrule 16 | \endhead 17 | \multicolumn{4}{r@{}}{(Continued on next page)}\\ 18 | \endfoot 19 | \endlastfoot 20 | {% for tuple in area_applicants %} 21 | {%- set term = tuple[0] -%} 22 | {%- set applicant = applicants[tuple[1]] -%} 23 | {%- set major = majors[applicant["专业"][0]] -%} 24 | {{ get_applicant_link(applicant, "") }} & {{ get_major_link(major, show_dept=false) }}\small{{"{"}}{{ major["院系"] }}{{"}"}} & {{ term[0] }} {{ term[1] }} & 25 | {%- if "__destination" in applicant -%} 26 | {{ get_program_link(programs[applicant["__destination"]], show_class=true) }} 27 | {%- else -%} 28 | N/A 29 | {%- endif -%} 30 | \\ 31 | {% if not loop.last %}\midrule{% endif %} 32 | {% endfor %} 33 | \bottomrule 34 | \end{tabularx} 35 | 36 | {% endfor %} 37 | -------------------------------------------------------------------------------- /templates/latex/applicant.jinja: -------------------------------------------------------------------------------- 1 | {% from "macros.jinja" import get_applicant_desc, get_major_link, get_program_desc, get_program_link, get_datapoint_status, get_area_tags %} 2 | 3 | \section{ 4 | {{ get_applicant_desc(applicant, majors[applicant["专业"][0]]["院系"]) }} {% if applicant["__destination"] -%} 5 | /\smaller{ {{ get_program_desc(programs[applicant["__destination"]]) }} } 6 | {%- endif %} 7 | } 8 | \label{applicant:{{ applicant["ID"] }}} 9 | 10 | \vspace{-1em} 11 | {{ get_area_tags(applicant["申请方向"]) }} 12 | 13 | \subsection{基本信息} 14 | 15 | \begin{description}[leftmargin=!,labelwidth=\widthof{\bfseries TOEFL/IELTSX}] 16 | 17 | {% if applicant["专业"] -%} 18 | \item[专业]{ {{ get_major_link(majors[applicant["专业"][0]], show_dept=false) }} } 19 | {%- endif %} 20 | 21 | {% if applicant["研究生专业"] -%} 22 | \item[研究生专业]{ {{ applicant["研究生专业"] | escape }} } 23 | {%- endif %} 24 | 25 | {% if applicant["GPA"] -%} 26 | \item[GPA]{ {{ applicant["GPA"] | escape }} } 27 | {%- if applicant["GPA说明"] %} ({{ applicant["GPA说明"] | escape }}){% endif -%} 28 | {%- endif %} 29 | 30 | {% if applicant["排名"] -%} 31 | \item[排名]{ {{ applicant["排名"] | escape }} } 32 | {%- endif %} 33 | 34 | {% if applicant["TOEFL/IELTS 总分"] -%} 35 | \item[TOEFL/IELTS]{ {{ applicant["TOEFL/IELTS 总分"] | escape }} } 36 | {%- if applicant["TOEFL/IELTS 口语"] -%} 37 | (R{{ applicant["TOEFL/IELTS 阅读"] | escape }}, L{{ applicant["TOEFL/IELTS 听力"] | escape }}, S{{ applicant["TOEFL/IELTS 口语"] | escape }}, W{{ applicant["TOEFL/IELTS 写作"] | escape }}) 38 | {%- endif %} 39 | {%- endif %} 40 | 41 | {% if applicant["GRE 总分 (V+Q)"] -%} 42 | \item[GRE]{ {{ applicant["GRE 总分 (V+Q)"] | escape }} } 43 | {%- if applicant["GRE Quantitative"] %} 44 | (V{{ applicant["GRE Verbal"] | escape }}, Q{{ applicant["GRE Quantitative"] | escape }}, W{{ applicant["GRE Writing"] | escape }}) 45 | {%- endif %} 46 | {%- endif %} 47 | 48 | {% if applicant["申请方向说明"] -%} 49 | \item[申请方向]{ 50 | {{ applicant["申请方向说明"] | escape }} 51 | } 52 | {%- endif %} 53 | 54 | {% if applicant["科研段数"] -%} 55 | \item[科研段数]{ {{ applicant["科研段数"] | escape }} } 56 | {%- endif %} 57 | 58 | {% if applicant["科研/实习经历"] -%} 59 | \item[科研/实习经历]{ 60 | \begin{markdown} 61 | {{ applicant["科研/实习经历"] | trim | fix_list }} 62 | \end{markdown} 63 | } 64 | {%- endif %} 65 | 66 | {% if applicant["其他经历"] -%} 67 | \item[其他经历]{ 68 | \begin{markdown} 69 | {{ applicant["其他经历"] | trim | fix_list }} 70 | \end{markdown} 71 | } 72 | {%- endif %} 73 | 74 | {% if applicant["推荐信#1"] or applicant["推荐信#2"] or applicant["推荐信#3"] -%} 75 | \item[推荐信]{ 76 | \begin{enumerate} 77 | {% if applicant["推荐信#1"] -%} 78 | \item {{ applicant["推荐信#1"]|join(" / ") | escape }} 79 | {%- endif %} 80 | {% if applicant["推荐信#2"] -%} 81 | \item {{ applicant["推荐信#2"]|join(" / ") | escape }} 82 | {%- endif %} 83 | {% if applicant["推荐信#3"] -%} 84 | \item {{ applicant["推荐信#3"]|join(" / ") | escape }} 85 | {%- endif %} 86 | \end{enumerate} 87 | } 88 | {%- endif %} 89 | 90 | {% if applicant["联系方式"] -%} 91 | \item[联系方式]{ {{ applicant["联系方式"] | escape }} } 92 | {%- endif %} 93 | 94 | {% if applicant["可提供的帮助"] -%} 95 | \item[可提供的帮助]{ {{ applicant["可提供的帮助"]|join(", ") | escape }} } 96 | {%- endif %} 97 | 98 | 99 | \end{description} 100 | 101 | {% if applicant["数据点"] -%} 102 | \subsection{申请项目} 103 | 104 | \begin{longtable}[l]{lll} 105 | \toprule 106 | \textbf{项目} & \textbf{学期} & \textbf{结果} \\ 107 | \midrule 108 | \endfirsthead 109 | \multicolumn{3}{l@{}}{(Continued)}\\ 110 | \toprule 111 | \textbf{项目} & \textbf{学期} & \textbf{结果} \\ 112 | \midrule 113 | \endhead 114 | \multicolumn{3}{r@{}}{(Continued on next page)}\\ 115 | \endfoot 116 | \endlastfoot 117 | {% for datapoint in applicant["数据点"] -%} 118 | {%- set datapoint = datapoints[datapoint] -%} 119 | {{ get_program_link(programs[datapoint["项目"][0]]) }} & {{ datapoint["学年"] }} {{ datapoint["学期"] }} & {{ get_datapoint_status(datapoint) }} \\ 120 | {% if not loop.last -%} 121 | \midrule 122 | {%- endif %} 123 | {% endfor %} 124 | \bottomrule 125 | \end{longtable} 126 | 127 | {%- endif %} 128 | 129 | {% if applicant["申请总结"] and applicant["申请总结"]|trim -%} 130 | \subsection{申请总结} 131 | 132 | \begin{markdown} 133 | {{ applicant["申请总结"] | fix_list }} 134 | \end{markdown} 135 | 136 | {%- endif %} 137 | -------------------------------------------------------------------------------- /templates/latex/macros.jinja: -------------------------------------------------------------------------------- 1 | {% macro get_applicant_desc(applicant, major) -%} 2 | {% if "姓名/昵称" in applicant %}{{ applicant["姓名/昵称"] }}{% else %}{{ applicant["ID"] }}{% endif %} 3 | {%- if major %}\smaller{ {{ major }} }{% endif %} 4 | {%- if show_term and applicant["__term"][0] %} - {{ applicant["__term"][0] }}{{ applicant["__term"][1] }}{% endif %} 5 | {%- endmacro %} 6 | 7 | {% macro get_applicant_link(applicant, major) -%} 8 | \hyperref[applicant:{{ applicant["ID"] }}]{ {{ get_applicant_desc(applicant, major) }} } 9 | {%- endmacro %} 10 | 11 | {% macro get_major_desc(major, show_dept=true) -%} 12 | {{ major["专业"] }}{% if show_dept %}({{ major["院系"] }}){% endif %} 13 | {%- endmacro %} 14 | 15 | {% macro get_major_link(major, show_dept=true) -%} 16 | {%- if major["院系"] == "本科外校" -%} 17 | {{ get_major_desc(major, show_dept=false) }} 18 | {%- else -%} 19 | \hyperref[major:{{ major["ID"] }}]{ {{ get_major_desc(major, show_dept) }} } 20 | {%- endif -%} 21 | {%- endmacro %} 22 | 23 | {% macro get_program_desc(program, show_school=true, show_class=true) -%} 24 | {{ program["项目"] | escape }}{% if show_school %}@{{ program["学校"] | escape }}{% endif %} 25 | {%- if show_class %}\smaller{\texttt{ {{ program["类别"] | escape }}{{ "}}" }}{% endif %} 26 | {%- endmacro %} 27 | 28 | {% macro get_program_link(program, show_school=true, show_class=true) -%} 29 | \hyperref[program:{{ program["ID"] }}]{ {{ get_program_desc(program, show_school, show_class) }}{{ "}" }} 30 | {%- endmacro %} 31 | 32 | {% macro get_datapoint_status(datapoint) -%} 33 | {%- set result = datapoint["结果"] -%} 34 | {%- set admit = result == "Admit" -%} 35 | {%- set reject = result == "Reject" -%} 36 | {%- set withdraw = result == "Withdraw" -%} 37 | {%- if datapoint["最终去向"] %}\colorbox{OliveGreen}{\color{white}{Chosen}}{% else %}{%- if admit %}\colorbox{OliveGreen!30}{% elif reject %}\colorbox{Red!30}{% elif withdraw %}\colorbox{YellowOrange!30}{% else %}\colorbox{Cerulean!30}{% endif %}{{ "{" }}{{ result if result else "Unknown" }}{{ "}" }}{% endif %} 38 | {%- endmacro %} 39 | 40 | {% macro get_area_link(area) -%} 41 | \hyperref[area:{{ area }}]{\colorbox{Gray!20}{\color{black}\texttt{{"{{"}}{{ area }}{{ "}}}}" }} 42 | {%- endmacro %} 43 | 44 | {% macro get_area_tags(areas) -%} 45 | {% for area in areas -%}{{ get_area_link(area) }} {% endfor %} 46 | {%- endmacro %} 47 | -------------------------------------------------------------------------------- /templates/latex/main.jinja: -------------------------------------------------------------------------------- 1 | {% from "macros.jinja" import get_major_desc %} 2 | \documentclass{report} 3 | \usepackage{geometry} 4 | \geometry{ 5 | a4paper, 6 | left=1in, 7 | right=1in, 8 | top=1in, 9 | bottom=1in, 10 | } 11 | \usepackage{ctex} 12 | \usepackage{multicol} 13 | \usepackage{subfiles} 14 | \usepackage[dvipsnames]{xcolor} 15 | \usepackage[colorlinks]{hyperref} 16 | \usepackage{relsize} 17 | 18 | \usepackage[most]{tcolorbox} 19 | {% raw %} 20 | \newtcolorbox{myquote}{% 21 | enhanced, breakable, 22 | size=fbox, 23 | frame hidden, boxrule=0pt, 24 | sharp corners, 25 | colback=Gray!20, 26 | left=7.5pt, right=7.5pt, top=7.5pt, bottom=7.5pt, 27 | borderline west={2pt}{0pt}{Gray!50}, 28 | } 29 | {% endraw %} 30 | 31 | \usepackage[ 32 | stripIndent, 33 | shiftHeadings=1, 34 | ]{markdown} 35 | {% raw %} 36 | \let\oldRenderHFive\markdownRendererHeadingFive 37 | \markdownSetup{ 38 | renderers = { 39 | link = {% 40 | \href{#2}{#1}% 41 | }, 42 | blockQuoteBegin={\begin{myquote}}, 43 | blockQuoteEnd={\end{myquote}}, 44 | % forbid h1-h3 45 | headingOne={\markdownRendererHeadingFour{#1}}, 46 | headingTwo={\markdownRendererHeadingFour{#1}}, 47 | headingThree={\markdownRendererHeadingFour{#1}}, 48 | % add an underline to h5 49 | headingFive={\oldRenderHFive{\underline{#1}}}, 50 | }, 51 | } 52 | {% endraw %} 53 | 54 | \usepackage{calc} 55 | \usepackage{enumitem} 56 | \usepackage{longtable} 57 | \usepackage{ltablex} 58 | \usepackage{booktabs} 59 | \usepackage{fancyhdr} 60 | \usepackage[export]{adjustbox} 61 | 62 | {# make figures equals \textwith #} 63 | {% raw %} 64 | \let\oldincludegraphics\includegraphics 65 | \renewcommand{\includegraphics}[2][]{% 66 | \oldincludegraphics[#1,max width=\linewidth]{#2} 67 | } 68 | {% endraw %} 69 | 70 | {# make figures fixed in position #} 71 | \usepackage{float} 72 | \makeatletter 73 | \renewcommand{\fps@figure}{H} 74 | \renewcommand{\fps@table}{H} 75 | \makeatother 76 | 77 | \setlength{\parindent}{0pt} 78 | \setlength{\parskip}{0.5\baselineskip} 79 | \setcounter{tocdepth}{1} 80 | {# restore spacing between toc items #} 81 | \usepackage{tocloft} 82 | \setlength{\cftbeforesecskip}{0pt} 83 | 84 | \pagestyle{fancy} 85 | {% raw %} 86 | \renewcommand{\chaptermark}[1]{\markboth{#1}{}} 87 | \renewcommand{\sectionmark}[1]{\markright{#1}{}} 88 | {% endraw %} 89 | \fancyhf{} 90 | \fancyhead[L]{\leftmark} 91 | \fancyhead[R]{\rightmark} 92 | \fancyfoot[C]{\thepage} 93 | 94 | \makeatletter 95 | \let\ps@plain\ps@empty 96 | \makeatother 97 | 98 | \begin{document} 99 | 100 | \keepXColumns {# needed for full-width tabularx to work #} 101 | 102 | \title{清华大学飞跃数据库\\\large{\href{https://database.feiyue.online}{\texttt{database.feiyue.online}}}} 103 | \date{ {{ build_date }} } 104 | \author{数据库贡献者} 105 | 106 | \maketitle 107 | \thispagestyle{empty} 108 | \hypersetup{linkcolor=black} 109 | \tableofcontents 110 | \hypersetup{linkcolor=RoyalBlue} 111 | 112 | \newpage 113 | 114 | \part{申请案例} 115 | 116 | {% for (year, term), term_applicants in applicants_by_term %} 117 | \chapter{ {{ year }} {{ term }} } 118 | {% for applicant in term_applicants %} 119 | \subfile{applicant/{{ applicants[applicant]["ID"] }}.tex} 120 | \newpage 121 | {% endfor %} 122 | {% endfor %} 123 | 124 | \part{索引} 125 | 126 | \chapter{项目} 127 | 128 | {% for school, school_programs in programs | groupby("学校") -%} 129 | \section{ {{ school | escape }} } 130 | {% for program in school_programs | sort(attribute="类别") -%} 131 | \subfile{program/{{ program["ID"] }}.tex} 132 | {% endfor %} 133 | {% endfor %} 134 | 135 | \chapter{专业} 136 | 137 | {% for major in majors if major["院系"] != "本科外校" -%} 138 | \section{ {{ get_major_desc(major, show_dept=true) }} } 139 | \label{major:{{ major["ID"] }}} 140 | \subfile{major/{{ major["ID"] }}.tex} 141 | \newpage 142 | {% endfor %} 143 | 144 | 145 | \chapter{方向} 146 | 147 | \subfile{all_areas.tex} 148 | 149 | \end{document} 150 | -------------------------------------------------------------------------------- /templates/latex/major.jinja: -------------------------------------------------------------------------------- 1 | {% from "macros.jinja" import get_major_desc, get_program_link, get_datapoint_status, get_applicant_link, get_area_tags %} 2 | 3 | {% for (year, term), term_applicants in major["__applicants_by_term"] %} 4 | {%- if term_applicants|length > 0%} 5 | \subsection*{ {{ year }} {{ term }} } 6 | 7 | \begin{tabularx}{\textwidth}{lllXX} 8 | \toprule 9 | \textbf{申请人} & \textbf{GPA} & \textbf{排名} & \textbf{申请方向} & \textbf{去向} \\ 10 | \midrule 11 | \endfirsthead 12 | \multicolumn{5}{l@{}}{(Continued)}\\ 13 | \toprule 14 | \textbf{申请人} & \textbf{GPA} & \textbf{排名} & \textbf{申请方向} & \textbf{去向} \\ 15 | \midrule 16 | \endhead 17 | \multicolumn{5}{r@{}}{(Continued on next page)}\\ 18 | \endfoot 19 | \endlastfoot 20 | {% for applicant in term_applicants -%} 21 | {%- set applicant = applicants[applicant] -%} 22 | {{ get_applicant_link(applicant, "") }} & {{ applicant["GPA"]|default("N/A")|escape }} & {{ applicant["排名"]|default("N/A")|escape }} & {{ get_area_tags(applicant["申请方向"]) }} & {% if "__destination" in applicant %}{{ get_program_link(programs[applicant["__destination"]]) }}{% else %}N/A{% endif %} \\ 23 | {% if not loop.last %}\midrule {% endif %} 24 | {% endfor %} 25 | \bottomrule 26 | \end{tabularx} 27 | 28 | {% endif %} 29 | {% endfor %} 30 | -------------------------------------------------------------------------------- /templates/latex/program.jinja: -------------------------------------------------------------------------------- 1 | {% from "macros.jinja" import get_applicant_desc, get_major_link, get_program_desc, 2 | get_program_link, get_datapoint_status, get_applicant_link %} 3 | 4 | \subsection[ 5 | {{ get_program_desc(program, show_school=false) }} 6 | ]{ {{ get_program_desc(program, show_school=true) }} } 7 | \label{program:{{ program["ID"] }}} 8 | 9 | {% for (year, term), term_applicants in program["__applicants_by_term"]%} 10 | {%- if term_applicants|length > 0%} 11 | \subsubsection{ {{ year }} {{ term }} } 12 | 13 | \begin{tabularx}{\textwidth}{lXXl} 14 | \toprule 15 | \textbf{申请人} & \textbf{专业} & \textbf{院系} & \textbf{结果} \\ 16 | \midrule 17 | \endfirsthead 18 | \multicolumn{4}{l@{}}{(Continued)}\\ 19 | \toprule 20 | \textbf{申请人} & \textbf{专业} & \textbf{院系} & \textbf{结果} \\ 21 | \midrule 22 | \endhead 23 | \multicolumn{4}{r@{}}{(Continued on next page)}\\ 24 | \endfoot 25 | \endlastfoot 26 | {% for applicant in term_applicants -%} 27 | {% set applicant = applicants[applicant] %} 28 | {%- set datapoint = program_datapoints | selectattr("申请人", "equalto", applicant["_id"]) | first -%} 29 | {%- set major = majors[applicant["专业"][0]] -%} 30 | {{ get_applicant_link(applicant, "") }} & {{ get_major_link(major, show_dept=false) }} & {{ major["院系"] }} & 31 | {%- if datapoint %}{{ get_datapoint_status(datapoint) }}{% endif %} \\ 32 | {% if not loop.last %}\midrule{% endif %} 33 | {% endfor %} 34 | \bottomrule 35 | \end{tabularx} 36 | 37 | {% endif %} 38 | {% endfor %} 39 | -------------------------------------------------------------------------------- /templates/mkdocs/applicant.jinja: -------------------------------------------------------------------------------- 1 | {%- from "macros.jinja" import get_applicant_desc, get_major_link, get_program_link, 2 | get_program_desc, get_datapoint_status, get_area_tags -%} 3 | --- 4 | comments: true 5 | title: {{ get_applicant_desc(applicant, majors[applicant["专业"][0]]["院系"], show_term=false) }} 6 | {%- if "__destination" in applicant %} 7 | / {{ get_program_desc(programs[applicant["__destination"]], show_icon=false) }} 8 | {%- endif %} 9 | --- 10 | 11 |

{{ get_applicant_desc(applicant, "", show_term=false) }}
12 | {{ majors[applicant["专业"][0]]["院系"]}} 13 | {%- if "__destination" in applicant %} 14 | / {{ get_program_desc(programs[applicant["__destination"]], show_icon=false) }} 15 | {%- endif %} 16 | 17 |

18 | 19 | {% if applicant["申请方向"] -%} 20 |
21 | {{ get_area_tags(applicant["申请方向"]) }} 22 |
23 | {%- endif %} 24 | 25 | 36 | 37 | ## 基本信息 38 | 39 |
    40 | {% if applicant["专业"] -%} 41 |
  • 专业:{{ get_major_link(majors[applicant["专业"][0]], show_dept=false) }}
  • 42 | {%- endif %} 43 | 44 | {% if applicant["研究生专业"] -%} 45 |
  • 研究生专业:{{ applicant["研究生专业"] }}
  • 46 | {%- endif %} 47 | 48 | {% if applicant["GPA"] -%} 49 |
  • GPA:{{ applicant["GPA"] }} 50 | {%- if applicant["GPA说明"] %} ({{ applicant["GPA说明"] }}){% endif -%} 51 |
  • 52 | {%- endif %} 53 | 54 | {% if applicant["排名"] -%} 55 |
  • 排名:{{ applicant["排名"] }}
  • 56 | {%- endif %} 57 | 58 | {% if applicant["科研段数"] -%} 59 |
  • 科研段数:{{ applicant["科研段数"] }}
  • 60 | {%- endif %} 61 | 62 | {% if applicant["TOEFL/IELTS 总分"] -%} 63 |
  • TOEFL/IELTS:{{ applicant["TOEFL/IELTS 总分"] }}{% if "TOEFL/IELTS 口语" in applicant %} (R{{ applicant["TOEFL/IELTS 阅读"] }}, L{{ applicant["TOEFL/IELTS 听力"] }}, S{{ applicant["TOEFL/IELTS 口语"] }}, W{{ applicant["TOEFL/IELTS 写作"] }}){%- endif %} 64 |
  • 65 | {%- endif %} 66 | 67 | {% if applicant["GRE 总分 (V+Q)"] -%} 68 |
  • GRE:{{ applicant["GRE 总分 (V+Q)"] }}{% if "GRE Quantitative" in applicant %} (V{{ applicant["GRE Verbal"] }}, Q{{ applicant["GRE Quantitative"] }}, W{{ applicant["GRE Writing"] }}){%- endif %} 69 |
  • 70 | {%- endif %} 71 | 72 | {% if applicant["联系方式"] -%} 73 |
  • 联系方式:{{ applicant["联系方式"] }}
  • 74 | {%- endif %} 75 | 76 | {% if applicant["可提供的帮助"] -%} 77 |
  • 可提供的帮助:{{ applicant["可提供的帮助"]|join(", ") }}
  • 78 | {%- endif %} 79 | 80 |
81 | 82 | {% if applicant["申请方向说明"] -%} 83 | **申请方向** 84 | 85 | {{ applicant["申请方向说明"] }} 86 | {%- endif %} 87 | 88 | 89 | {% if applicant["科研/实习经历"] -%} 90 | **科研/实习经历** 91 | 92 | {{ applicant["科研/实习经历"] }} 93 | {%- endif %} 94 | 95 | {% if applicant["其他经历"] -%} 96 | **其他经历** 97 | 98 | {{ applicant["其他经历"] }} 99 | {%- endif %} 100 | 101 | {% if applicant["推荐信#1"] or applicant["推荐信#2"] or applicant["推荐信#3"] -%} 102 | **推荐信** 103 | 104 | {% if applicant["推荐信#1"] -%} 105 | 1. {{ applicant["推荐信#1"]|join(", ") }} 106 | {%- endif %} 107 | {% if applicant["推荐信#2"] -%} 108 | 2. {{ applicant["推荐信#2"]|join(", ") }} 109 | {%- endif %} 110 | {% if applicant["推荐信#3"] -%} 111 | 3. {{ applicant["推荐信#3"]|join(", ") }} 112 | {%- endif %} 113 | 114 | {%- endif %} 115 | 116 | {% if applicant["数据点"] -%} 117 | ## 申请项目 118 | 119 | | 项目 | 学期 | 结果 | 120 | | --- | --- | --- | 121 | {% for datapoint in applicant["数据点"] -%} 122 | {%- set datapoint = datapoints[datapoint] -%} 123 | | {{ get_program_link(programs[datapoint["项目"][0]]) }} | {{ datapoint["学年"] }} {{ datapoint["学期"] }} | {{ get_datapoint_status(datapoint) }} | 124 | {% endfor %} 125 | {%- endif %} 126 | 127 | {% if applicant["申请总结"] and applicant["申请总结"]|trim -%} 128 | ## 申请总结 129 |
130 | 131 | {{ applicant["申请总结"] | replace("\\@", "@") }} 132 | 133 |
134 | {%- endif %} 135 | -------------------------------------------------------------------------------- /templates/mkdocs/applicant_index.jinja: -------------------------------------------------------------------------------- 1 | # 申请案例 2 | 3 | {%- from "macros.jinja" import get_applicant_link, get_major_link, get_program_link, get_area_tags -%} 4 | 5 | {% for (year, term), term_applicant_ids in applicants_by_term %} 6 | #### {{ year }} {{ term }} 7 | {% set term_applicants = [] -%} 8 | {% for applicant in term_applicant_ids -%} 9 | {{- term_applicants.append(applicants[applicant]) or "" }} 10 | {%- endfor %} 11 | 12 | | 申请人 | 专业 | 申请方向 | 去向 | 13 | | --- | --- | --- | --- | 14 | {% for applicant in term_applicants | sort(attribute="专业") -%} 15 | {%- set major = majors[applicant["专业"][0]] -%} 16 | | {{ get_applicant_link(applicant, "", false) }} | {{ get_major_link(major, show_dept=false) }} {{ major["院系"] }} | {{ get_area_tags(applicant["申请方向"]) }} | 17 | {%- if "__destination" in applicant -%} 18 | {{ get_program_link(programs[applicant["__destination"]], show_icon=true) }} 19 | {%- else -%} 20 | N/A 21 | {%- endif -%} 22 | | 23 | {% endfor %} 24 | {%- endfor %} 25 | -------------------------------------------------------------------------------- /templates/mkdocs/area_index.jinja: -------------------------------------------------------------------------------- 1 | {%- from "macros.jinja" import get_area_tags, get_applicant_link, get_program_link, get_major_link -%} 2 | 3 | # 申请方向 4 | 5 | {{ get_area_tags(all_areas.keys(), same_page=true) }} 6 | 7 | {% for area, area_applicants in all_areas.items() %} 8 | #### {{ area }} 9 | 10 | | 申请人 | 专业 | 学期 | 去向 | 11 | | --- | --- | --- | --- | 12 | {% for tuple in area_applicants %} 13 | {%- set term = tuple[0] -%} 14 | {%- set applicant = applicants[tuple[1]] -%} 15 | {%- set major = majors[applicant["专业"][0]] -%} 16 | | {{ get_applicant_link(applicant, "", false, base=".") }} | {{ get_major_link(major, show_dept=false, base=".") }} {{ major["院系"] }}| {{ term[0] }} {{ term[1] }} | 17 | {%- if "__destination" in applicant -%} 18 | {{ get_program_link(programs[applicant["__destination"]], show_icon=true, base=".") }} 19 | {%- else -%} 20 | N/A 21 | {%- endif -%} 22 | | 23 | {% endfor %} 24 | {% endfor %} -------------------------------------------------------------------------------- /templates/mkdocs/index.jinja: -------------------------------------------------------------------------------- 1 | --- 2 | title: 主页 3 | --- 4 | 5 | 16 | 17 | {%- macro create_card(title, desc, button_text, button_dest, external=false) -%} 18 |
  • 19 |
    20 |
    21 | {{ title }}
    22 | {{ desc }} 23 |
    24 |
    25 | {{ button_text }} 26 |
    27 |
    28 |
  • 29 | 30 | {%- endmacro %} 31 | 32 | 欢迎浏览清华大学飞跃数据库!这是一个收集并展示清华大学出国申请案例的数据库,旨在帮助同学们更好地了解往届同学的申请情况,为自己的申请提供参考。我们将申请案例进行了归类,您可以按照自己的需求查看不同专业、不同项目的申请情况。您还可以浏览完整数据库,根据自己的需求筛选、分析数据。如有疑问,请参考[常见问题](./faq.md)。 33 | 34 | 关于申请常识、信息资源、准备方法等,请移步[清华大学飞跃手册](https://feiyue.online){:target="_blank"}。 35 | 36 | 祝您申请顺利! 37 | 38 |
    39 |
      40 | 41 | {{ create_card("申请案例", "查看 " + applicant_num|string +" 个申请人的背景、申请结果、申请总结。", "查看 :material-arrow-right:", "./applicant") }} 42 | {{ create_card("按专业查看", "查看 " + major_num|string +" 个本科专业的申请案例。", "查看 :material-arrow-right:", "./major") }} 43 | {{ create_card("按方向查看", "查看 " + area_num|string +" 个申请方向的案例。", "查看 :material-arrow-right:", "./area") }} 44 | {{ create_card("按项目查看", "查看 " + program_num|string +" 个项目的申请情况及其申请人的案例。", "查看 :material-arrow-right:", "./program") }} 45 | {{ create_card("查看完整数据库", "浏览实时存储于 SeaTable 上的原始数据,根据需要筛选、分析数据。", "前往 :material-arrow-top-right:", "https://cloud.seatable.io/dtable/external-links/custom/thu-feiyue/", true) }} 46 | 47 |
    48 |
    49 | 50 | ## 贡献数据 51 | 52 | **本文档十分欢迎并且非常需要您的贡献!**为了方便贡献数据,我们简化了提交数据的流程——您只需要填写一个在线表格即可。 53 | 54 | 本文档并不依赖于手动编写的 Markdown 文档,而是依赖于保存到 SeaTable 数据库中的数据——这使得对数据进行分类、分析成为可能。本文档 6 小时自动更新一次,更新时会从 SeaTable 数据库中读取最新数据,并生成对应的网页。 55 | 56 | 如果您想要贡献数据,请按照下面的步骤进行。我们建议您仔细阅读[贡献说明](./contribute.md)后,再填写表单。 57 | 58 |
    59 |
      60 | 61 | {{ create_card("第一步:创建申请人", "创建个人资料,只需填写一次。创建后可以点击「修改个人资料」更改。[查看帮助](./contribute.md#第一步创建申请人信息)", "填写 :material-arrow-top-right:", "https://cloud.seatable.io/dtable/forms/b0691605-791c-4504-b07e-6f3c89b4165e/", true) }} 62 | {{ create_card("第二步:提交/修改数据点", "添加申请项目的信息,或者更新申请状态。提交的申请信息与账号相关联,只能修改自己创建的申请信息。[查看帮助](./contribute.md#第二步添加数据点)", "填写 :material-arrow-top-right:", "https://cloud.seatable.io/dtable/collection-tables/2695773c-aa8e-4f14-a95f-e6acd9cf010d/", true) }} 63 | {{ create_card("后续:修改/删除个人资料", "修改或删除个人资料。", "填写 :material-arrow-top-right:", "https://cloud.seatable.io/dtable/collection-tables/304f1ac0-eb9c-4e91-8794-72e98bbbb383/", true) }} 64 | 65 |
    66 |
    67 | 68 | 为了帮助更多的同学,本文档不设查看或编辑限制,对任何人开放。请勿进行破坏性的操作,包括但不限于添加恶意或虚假数据、链接其他申请者的信息等。在表单中提交文字或内容即表示您同意将其在 [CC BY-NC-SA 4.0](https://creativecommons.org/licenses/by-nc-sa/4.0/) 协议下发布。这意味着其他人可以在非商业性目的下自由分享和修改您的内容,但必须提到您作为原作者,并且以相同的许可协议分享任何修改或衍生作品。 69 | 70 | ## 致谢 71 | 72 | 本文档及数据库的设计参考了浙江大学飞跃手册及 [Open CS Application](https://opencs.app) 网站。 73 | -------------------------------------------------------------------------------- /templates/mkdocs/macros.jinja: -------------------------------------------------------------------------------- 1 | {% macro get_applicant_desc(applicant, major, show_term=true) -%} 2 | {% if "姓名/昵称" in applicant %}{{ applicant["姓名/昵称"] }}{% else %}{{ applicant["ID"] }}{% endif %}{% if major %} - {{ major }}{% endif %}{% if show_term and applicant["__term"][0] %} - {{ applicant["__term"][0] }}{{ applicant["__term"][1] }}{% endif %} 3 | {%- endmacro %} 4 | 5 | {% macro get_major_desc(major, show_dept=true) -%} 6 | {{ major["专业"] }}{% if show_dept %}({{ major["院系"] }}){% endif %} 7 | {%- endmacro %} 8 | 9 | {% macro get_program_icon(program) -%} 10 | {{ program["类别"] }} 11 | {%- endmacro %} 12 | 13 | {% macro get_program_desc(program, show_icon=true, show_school=true) -%} 14 | {{ program["项目"] }}{% if show_school %}@{{ program["学校"] }}{% endif %}{% if show_icon %} {{ get_program_icon(program) }}{% endif %} 15 | {%- endmacro %} 16 | 17 | {% macro get_major_link(major, show_dept=true, base="..") -%} 18 | {%- if major["院系"] == "本科外校" -%} 19 | {{ get_major_desc(major, show_dept=false) }} 20 | {%- else -%} 21 | [{{ get_major_desc(major, show_dept) }}]({{ base }}/major/{{ major["ID"] }}.md) 22 | {%- endif -%} 23 | {%- endmacro %} 24 | 25 | {% macro get_program_link(program, show_icon=true, show_school=true, base="..") -%} 26 | [{{ get_program_desc(program, show_icon, show_school) }}]({{ base }}/program/{{ program["ID"] }}.md) 27 | {%- endmacro %} 28 | 29 | {% macro get_applicant_link(applicant, major, show_term=true, base="..") -%} 30 | [{{ get_applicant_desc(applicant, major, show_term) }}]({{ base }}/applicant/{{ applicant["ID"] }}.md) 31 | {%- endmacro %} 32 | 33 | {% macro get_datapoint_status(datapoint) -%} 34 | {%- set result = datapoint["结果"] -%} 35 | {%- set admit = result == "Admit" -%} 36 | {%- set reject = result == "Reject" -%} 37 | {%- set withdraw = result == "Withdraw" -%} 38 | {%- if datapoint["最终去向"] %}:white_check_mark: Chosen{% else %}{%- if admit %}:green_circle:{% elif reject %}:red_circle:{% elif withdraw %}:orange_circle:{% else %}:blue_circle:{% endif %} {{ result if result else "Unknown" }}{% endif %} 39 | {%- endmacro %} 40 | 41 | {% macro get_area_link(area, same_page=false) -%} 42 | {{ area }} 43 | {%- endmacro %} 44 | 45 | {% macro get_area_tags(areas, same_page=false) -%} 46 | {% if areas -%} 47 |
    48 | {%- for area in areas | sort -%} 49 | {{ get_area_link(area, same_page) }} 50 | {%- endfor -%} 51 |
    52 | {%- endif -%} 53 | {%- endmacro %} 54 | 55 | {% macro make_metric_card(title, icon) %} 56 |
  • 57 |
    58 |
    59 | {{ icon }} {{ title }} 60 |
    61 |
    62 | 63 | {{ caller() }} 64 | 65 |
    66 |
    67 |
  • 68 | {% endmacro %} 69 | 70 | {% macro make_horizontal_lined(leading, trailing) %} 71 | 72 | {{ leading }} 73 |
    74 | {{ trailing }} 75 |
    76 | {% endmacro %} -------------------------------------------------------------------------------- /templates/mkdocs/major.jinja: -------------------------------------------------------------------------------- 1 | {%- from "macros.jinja" import get_applicant_desc, get_program_link, get_program_desc, get_applicant_link, make_metric_card, make_horizontal_lined, get_area_tags -%} 2 | --- 3 | title: {{ major["专业"] }}({{ major["院系"] }}) 4 | --- 5 | 6 |

    {{ major["专业"] }}
    {{ major["院系"] }}

    7 | 8 |
    9 |
      10 | {% call make_metric_card("总案例数", ":material-archive-outline:") %} 11 | {{ major["申请人"]|length}} 12 | {% endcall %} 13 | {% call make_metric_card("GPA 中位数", ":material-chart-bar:") %} 14 | {% if major["__gpa_median"] != None %}{{ major["__gpa_median"] }}{% else %}N/A{% endif %} 15 | {% endcall %} 16 | {% call make_metric_card("最多申请", ":material-star-outline:") %} 17 | {% if major["__programs"]|length > 0 %}{{ get_program_desc(programs[major["__programs"][0][0]], show_icon=false) }}{% else %}N/A{% endif %} 18 | {% endcall %} 19 | {% call make_metric_card("人均申请", ":material-format-list-numbered:") %} 20 | {% if major["__program_count"] > 0%}{{ major["__program_count"] / major["申请人"]|length }} 个项目{% else %}N/A{% endif %} 21 | {% endcall %} 22 | 23 |
    24 |
    25 | 26 | ### 申请人数最多的项目 27 | 28 |
      29 | {% for program in major["__programs"][:10] -%} 30 |
    1. {{ make_horizontal_lined(get_program_link(programs[program[0]], show_icon=true), program[1] | string + " 人") }}
    2. 31 | {% endfor %} 32 |
    33 | 34 | ### 申请案例 35 | {% for (year, term), term_applicants in major["__applicants_by_term"] %} 36 | {%- if term_applicants|length > 0%} 37 | **{{ year }} {{ term }}** 38 | 39 | | 申请人 | GPA | 排名 | 申请方向 | 去向 | 40 | | --- | --- | --- | --- | --- | 41 | {% for applicant in term_applicants -%} 42 | {%- set applicant = applicants[applicant] -%} 43 | | {{ get_applicant_link(applicant, show_term=false) }} | {{ applicant["GPA"]|default("N/A") }} | {{ applicant["排名"]|default("N/A") }} | {{ get_area_tags(applicant["申请方向"]) }} | {% if "__destination" in applicant %}{{ get_program_link(programs[applicant["__destination"]]) }}{% else %}N/A{% endif %} | 44 | {% endfor %} 45 | {% endif %} 46 | {% endfor %} -------------------------------------------------------------------------------- /templates/mkdocs/major_index.jinja: -------------------------------------------------------------------------------- 1 | # 本科专业列表 2 | 3 | {%- from "macros.jinja" import get_major_link, make_horizontal_lined %} 4 | 5 | 6 |
      7 | 8 | {% for major in majors if major["院系"] != "本科外校" -%} 9 |
    • {{ make_horizontal_lined(get_major_link(major), major["申请人"] | length | string + " 个案例") }}
    • 10 | {% endfor %} 11 | 12 |
    13 | -------------------------------------------------------------------------------- /templates/mkdocs/mkdocs_config.jinja: -------------------------------------------------------------------------------- 1 | {%- from "macros.jinja" import get_program_icon, get_applicant_desc, get_program_desc -%} 2 | site_name: 清华大学飞跃数据库 3 | site_url: https://database.feiyue.online/ 4 | repo_url: https://github.com/THU-feiyue/database/ 5 | repo_name: THU-feiyue/database 6 | edit_uri: "" 7 | copyright: 更新于 {{ build_time }} 8 | 9 | 10 | theme: 11 | name: material 12 | language: zh 13 | features: 14 | - navigation.tabs 15 | - navigation.indexes 16 | - announce.dismiss 17 | custom_dir: overrides 18 | 19 | palette: 20 | - media: "(prefers-color-scheme)" 21 | toggle: 22 | icon: material/brightness-auto 23 | name: Switch to light mode 24 | - media: "(prefers-color-scheme: light)" 25 | scheme: default 26 | toggle: 27 | icon: material/brightness-7 28 | name: Switch to dark mode 29 | - media: "(prefers-color-scheme: dark)" 30 | scheme: slate 31 | primary: black 32 | accent: indigo 33 | toggle: 34 | icon: material/brightness-4 35 | name: Switch to system preference 36 | 37 | markdown_extensions: 38 | - admonition 39 | - pymdownx.details 40 | - pymdownx.highlight 41 | - pymdownx.superfences 42 | - toc: 43 | permalink: true 44 | slugify: !!python/object/apply:pymdownx.slugs.slugify 45 | kwds: 46 | case: lower 47 | - attr_list 48 | - pymdownx.emoji: 49 | emoji_index: !!python/name:material.extensions.emoji.twemoji 50 | emoji_generator: !!python/name:material.extensions.emoji.to_svg 51 | - md_in_html 52 | - mdx_truly_sane_lists 53 | - pymdownx.tabbed: 54 | alternate_style: true 55 | 56 | extra_css: 57 | - stylesheets/extra.css 58 | - stylesheets/font.css 59 | 60 | extra: 61 | analytics: 62 | provider: google 63 | property: G-E16X93G09M 64 | 65 | plugins: 66 | - awesome-pages 67 | - search 68 | 69 | nav: 70 | - 主页: 71 | - index.md 72 | - contribute.md 73 | - faq.md 74 | - feedback.md 75 | - 案例: [ 76 | "applicant/index.md", 77 | {% for (year, term), term_applicants in applicants_by_term %} 78 | { 79 | "{{ year }} {{ term }}": 80 | [ 81 | {% for applicant in term_applicants -%} 82 | {% set applicant = all_applicants[applicant] -%} 83 | "{{ get_applicant_desc(applicant, "", show_term=false) }} / {{ all_majors[applicant["专业"][0]]["院系"]}} 84 | {%- if "__destination" in applicant %}
    {{ get_program_desc(all_programs[applicant["__destination"]], show_icon=false) }} 85 | {%- endif -%}": "applicant/{{ applicant["ID"] }}.md"{% if loop.index != term_applicants|length %},{% endif %} 86 | {% endfor %} 87 | ] 88 | }{% if loop.index != applicants_by_term|length %},{% endif %} 89 | {% endfor %} 90 | ] 91 | - 专业: [ 92 | "major/index.md", 93 | {% for major in majors if major["院系"] != "本科外校" -%} 94 | "{{ major["专业"] }} / {{ major["院系"] }}": "major/{{ major["ID"] }}.md"{% if loop.index != majors|length %},{% endif %} 95 | {%- endfor %} 96 | ] 97 | - 方向: "area.md" 98 | - 项目: [ 99 | "program/index.md", 100 | {% for school, school_programs in programs | groupby("学校") -%} 101 | { 102 | "{{ school }}": 103 | [ 104 | {% for program in school_programs | sort(attribute="类别") -%} 105 | "{{ program["项目"] }} {{ get_program_icon(program) }}": "program/{{ program["ID"] }}.md"{% if loop.index != school_programs|length %},{% endif %} 106 | {% endfor %} 107 | ] 108 | }{% if loop.index != programs|length %},{% endif %} 109 | {% endfor %} 110 | ] 111 | -------------------------------------------------------------------------------- /templates/mkdocs/program.jinja: -------------------------------------------------------------------------------- 1 | {%- from "macros.jinja" import get_applicant_link, get_datapoint_status, get_program_icon, get_major_link, make_metric_card -%} 2 | --- 3 | title: {{ program["项目"] }}@{{ program["学校"] }} 4 | comments: true 5 | --- 6 | 7 |

    {{ program["项目"] }} {{ program["类别"] }}
    {{ program["学校"] }}

    8 | 9 | {%- set admitted_datapoints = program_datapoints | selectattr("结果", "equalto", "Admit") | list %} 10 | {%- set reject_datapoints = program_datapoints | selectattr("结果", "equalto", "Reject") | list %} 11 | {%- set finalized_datapoints_num = admitted_datapoints | length + reject_datapoints | length %} 12 | 13 |
    14 |
      15 | {% call make_metric_card("总案例数", ":material-archive-outline:") %} 16 | {{ program_datapoints | length }} 17 | 18 | / 19 | {% if admitted_datapoints | length %}{{ admitted_datapoints | length }}Ad {% endif %} 20 | {% if reject_datapoints | length %}{{ reject_datapoints | length }}Rej {% endif %} 21 | {% if program_datapoints | length - finalized_datapoints_num %}{{ program_datapoints | length - finalized_datapoints_num }}Pending{% endif %} 22 | 23 | 24 | {% endcall %} 25 | 26 | {% call make_metric_card("录取率", ":material-checkbox-marked-circle-outline:") %} 27 | {% if finalized_datapoints_num > 0 %}{{ 100 * admitted_datapoints | length // finalized_datapoints_num }}%{% else %}N/A{% endif %} 28 | {% endcall %} 29 | 30 |
    31 |
    32 | 33 | ### 申请案例 34 | {% for (year, term), term_applicants in program["__applicants_by_term"]%} 35 | {%- if term_applicants|length > 0%} 36 | **{{ year }} {{ term }}** 37 | 38 | | 申请人 | 专业 | 院系 | 结果 | 39 | | --- | --- | --- | --- | 40 | {% for applicant in term_applicants -%} 41 | {%- set datapoint = program_datapoints | selectattr("申请人", "equalto", applicant) | first -%} 42 | {%- set major = majors[applicants[applicant]["专业"][0]] -%} 43 | {{ get_applicant_link(applicants[applicant], "", false) }} | {{ get_major_link(major, show_dept=false) }} | {{ major["院系"] }} | {{ get_datapoint_status(datapoint) }} 44 | {% endfor %} 45 | {% endif %} 46 | {% endfor %} 47 | -------------------------------------------------------------------------------- /templates/mkdocs/program_index.jinja: -------------------------------------------------------------------------------- 1 | # 项目列表 2 | 3 | {%- from "macros.jinja" import get_program_link, make_horizontal_lined %} 4 | 5 | {% for school, school_programs in programs | groupby("学校") %} 6 | **{{ school }}** 7 | 8 |
      9 | {% for program in school_programs | sort(attribute="类别") -%} 10 |
    • {{ make_horizontal_lined(get_program_link(program, show_icon=true, show_school=false), program["数据点"] | length | string + " 个数据点") }}
    • 11 | {% endfor %} 12 |
    13 | {% endfor %} 14 | --------------------------------------------------------------------------------