From 59bf217afa519f64ba9b2e97424cd9a1cb2a9bf9 Mon Sep 17 00:00:00 2001 From: 0xkrypton <154910746+oxkrypton@users.noreply.github.com> Date: Tue, 18 Aug 2026 10:20:26 +0800 Subject: [PATCH 1/9] rm qq-jobs-sync.yml --- .github/workflows/qq-jobs-sync.yml | 176 ----------------------------- 1 file changed, 176 deletions(-) diff --git a/.github/workflows/qq-jobs-sync.yml b/.github/workflows/qq-jobs-sync.yml index ab287ab..8b13789 100644 --- a/.github/workflows/qq-jobs-sync.yml +++ b/.github/workflows/qq-jobs-sync.yml @@ -1,177 +1 @@ -name: 同步招聘信息 -on: - schedule: - - cron: "0 */3 * * *" - workflow_dispatch: - inputs: - probe_only: - description: 只验证腾讯文档完整性,不修改内容 - required: false - default: false - type: boolean - accept_source_deletions: - description: 确认将本次缺失记录标记为源表已移除 - required: false - default: false - type: boolean - -permissions: - actions: write - contents: write - issues: write - -concurrency: - group: ${{ github.repository }}-qq-jobs-sync - cancel-in-progress: false - -env: - DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} - HUGO_VERSION: 0.159.0 - PLAYWRIGHT_VERSION: 1.55.0 - PYTHONDONTWRITEBYTECODE: "1" - -jobs: - sync: - name: 校验、同步并发布 - runs-on: ubuntu-latest - timeout-minutes: 30 - steps: - - name: 检出最新默认分支 - uses: actions/checkout@v4 - with: - fetch-depth: 0 - ref: ${{ github.event.repository.default_branch }} - submodules: false - - - name: 安装 Python - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - - name: 还原专用账号会话 - shell: bash - env: - QQ_STORAGE_STATE_B64: ${{ secrets.QQ_DOCS_STORAGE_STATE_B64 }} - run: | - if [[ -z "$QQ_STORAGE_STATE_B64" ]]; then - echo "缺少 QQ_DOCS_STORAGE_STATE_B64。" >&2 - exit 1 - fi - STORAGE_STATE="$RUNNER_TEMP/qq-docs-storage-state.json" - printf '%s' "$QQ_STORAGE_STATE_B64" | base64 --decode > "$STORAGE_STATE" - chmod 600 "$STORAGE_STATE" - STORAGE_STATE="$STORAGE_STATE" \ - python3 -c 'import json, os; json.load(open(os.environ["STORAGE_STATE"], encoding="utf-8"))' - echo "STORAGE_STATE=$STORAGE_STATE" >> "$GITHUB_ENV" - - - name: 安装 Playwright Chromium - run: | - python3 -m pip install --disable-pip-version-check "playwright==$PLAYWRIGHT_VERSION" - python3 -m playwright install --with-deps chromium - - - name: 运行同步测试 - run: python3 -m unittest tests.test_sync_qq_jobs - - - name: 双遍读取并校验腾讯文档 - shell: bash - run: | - args=( - --storage-state "$STORAGE_STATE" - --timeout-seconds 120 - ) - if [[ "${{ inputs.probe_only || false }}" == "true" ]]; then - args+=(--probe-only) - else - args+=(--check-links) - fi - if [[ "${{ github.event_name }}" == "workflow_dispatch" && "${{ inputs.accept_source_deletions || false }}" == "true" ]]; then - args+=(--accept-source-deletions) - fi - python3 scripts/sync_qq_jobs.py "${args[@]}" - - - name: 检查生成文件范围 - id: changes - if: ${{ !(github.event_name == 'workflow_dispatch' && inputs.probe_only) }} - shell: bash - run: | - allowed='^(data/jobs/(daily-updates|early-recruitment)\.json|content/docs/jobs/(每日更新|秋招提前批)\.md)$' - changed_files="$(git -c core.quotepath=false diff --name-only)" - unexpected="$(printf '%s\n' "$changed_files" | sed '/^$/d' | grep -Ev "$allowed" || true)" - if [[ -n "$unexpected" ]]; then - echo "同步产生了白名单外改动:" >&2 - echo "$unexpected" >&2 - exit 1 - fi - if [[ -z "$changed_files" ]]; then - echo "updated=false" >> "$GITHUB_OUTPUT" - else - echo "updated=true" >> "$GITHUB_OUTPUT" - git diff --check -- \ - data/jobs/daily-updates.json \ - data/jobs/early-recruitment.json \ - content/docs/jobs/每日更新.md \ - content/docs/jobs/秋招提前批.md - fi - - - name: 初始化主题子模块 - if: steps.changes.outputs.updated == 'true' - run: git submodule update --init --recursive - - - name: 安装 Hugo - if: steps.changes.outputs.updated == 'true' - uses: peaceiris/actions-hugo@v3 - with: - hugo-version: ${{ env.HUGO_VERSION }} - extended: true - - - name: 构建整站 - if: steps.changes.outputs.updated == 'true' - run: hugo --minify - - - name: 校验搜索索引收录 - if: steps.changes.outputs.updated == 'true' - run: python3 scripts/check_search_index.py --site public - - - name: 提交并推送招聘信息 - if: steps.changes.outputs.updated == 'true' - shell: bash - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add -- \ - data/jobs/daily-updates.json \ - data/jobs/early-recruitment.json \ - content/docs/jobs/每日更新.md \ - content/docs/jobs/秋招提前批.md - git commit -m "更新招聘信息" - git push origin "HEAD:$DEFAULT_BRANCH" - - - name: 触发现有 Pages 发布工作流 - if: steps.changes.outputs.updated == 'true' - env: - GH_TOKEN: ${{ github.token }} - run: gh workflow run static.yml --ref "$DEFAULT_BRANCH" - - report_failure: - name: 报告同步失败 - needs: sync - if: ${{ always() && needs.sync.result == 'failure' }} - runs-on: ubuntu-latest - steps: - - name: 创建或更新故障 Issue - env: - GH_TOKEN: ${{ github.token }} - GH_REPO: ${{ github.repository }} - RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - shell: bash - run: | - title='招聘信息同步失败' - printf -v body '招聘信息同步未通过完整性门槛,现有站点内容保持不变。\n\n运行:%s\n触发方式:%s\n提交:%s' \ - "$RUN_URL" "${{ github.event_name }}" "${{ github.sha }}" - issue_number="$(gh issue list --state open --search "$title in:title" --json number,title --jq '.[] | select(.title == "招聘信息同步失败") | .number' | head -n 1)" - if [[ -n "$issue_number" ]]; then - gh issue edit "$issue_number" --body "$body" - else - gh issue create --title "$title" --body "$body" - fi From ba8e24e1abeef19b90c66b006541f85bf814b7aa Mon Sep 17 00:00:00 2001 From: 0xkrypton <154910746+oxkrypton@users.noreply.github.com> Date: Tue, 18 Aug 2026 10:26:16 +0800 Subject: [PATCH 2/9] Update qq-jobs-sync.yml --- .github/workflows/qq-jobs-sync.yml | 176 +++++++++++++++++++++++++++++ 1 file changed, 176 insertions(+) diff --git a/.github/workflows/qq-jobs-sync.yml b/.github/workflows/qq-jobs-sync.yml index 8b13789..ab287ab 100644 --- a/.github/workflows/qq-jobs-sync.yml +++ b/.github/workflows/qq-jobs-sync.yml @@ -1 +1,177 @@ +name: 同步招聘信息 +on: + schedule: + - cron: "0 */3 * * *" + workflow_dispatch: + inputs: + probe_only: + description: 只验证腾讯文档完整性,不修改内容 + required: false + default: false + type: boolean + accept_source_deletions: + description: 确认将本次缺失记录标记为源表已移除 + required: false + default: false + type: boolean + +permissions: + actions: write + contents: write + issues: write + +concurrency: + group: ${{ github.repository }}-qq-jobs-sync + cancel-in-progress: false + +env: + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + HUGO_VERSION: 0.159.0 + PLAYWRIGHT_VERSION: 1.55.0 + PYTHONDONTWRITEBYTECODE: "1" + +jobs: + sync: + name: 校验、同步并发布 + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: 检出最新默认分支 + uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{ github.event.repository.default_branch }} + submodules: false + + - name: 安装 Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: 还原专用账号会话 + shell: bash + env: + QQ_STORAGE_STATE_B64: ${{ secrets.QQ_DOCS_STORAGE_STATE_B64 }} + run: | + if [[ -z "$QQ_STORAGE_STATE_B64" ]]; then + echo "缺少 QQ_DOCS_STORAGE_STATE_B64。" >&2 + exit 1 + fi + STORAGE_STATE="$RUNNER_TEMP/qq-docs-storage-state.json" + printf '%s' "$QQ_STORAGE_STATE_B64" | base64 --decode > "$STORAGE_STATE" + chmod 600 "$STORAGE_STATE" + STORAGE_STATE="$STORAGE_STATE" \ + python3 -c 'import json, os; json.load(open(os.environ["STORAGE_STATE"], encoding="utf-8"))' + echo "STORAGE_STATE=$STORAGE_STATE" >> "$GITHUB_ENV" + + - name: 安装 Playwright Chromium + run: | + python3 -m pip install --disable-pip-version-check "playwright==$PLAYWRIGHT_VERSION" + python3 -m playwright install --with-deps chromium + + - name: 运行同步测试 + run: python3 -m unittest tests.test_sync_qq_jobs + + - name: 双遍读取并校验腾讯文档 + shell: bash + run: | + args=( + --storage-state "$STORAGE_STATE" + --timeout-seconds 120 + ) + if [[ "${{ inputs.probe_only || false }}" == "true" ]]; then + args+=(--probe-only) + else + args+=(--check-links) + fi + if [[ "${{ github.event_name }}" == "workflow_dispatch" && "${{ inputs.accept_source_deletions || false }}" == "true" ]]; then + args+=(--accept-source-deletions) + fi + python3 scripts/sync_qq_jobs.py "${args[@]}" + + - name: 检查生成文件范围 + id: changes + if: ${{ !(github.event_name == 'workflow_dispatch' && inputs.probe_only) }} + shell: bash + run: | + allowed='^(data/jobs/(daily-updates|early-recruitment)\.json|content/docs/jobs/(每日更新|秋招提前批)\.md)$' + changed_files="$(git -c core.quotepath=false diff --name-only)" + unexpected="$(printf '%s\n' "$changed_files" | sed '/^$/d' | grep -Ev "$allowed" || true)" + if [[ -n "$unexpected" ]]; then + echo "同步产生了白名单外改动:" >&2 + echo "$unexpected" >&2 + exit 1 + fi + if [[ -z "$changed_files" ]]; then + echo "updated=false" >> "$GITHUB_OUTPUT" + else + echo "updated=true" >> "$GITHUB_OUTPUT" + git diff --check -- \ + data/jobs/daily-updates.json \ + data/jobs/early-recruitment.json \ + content/docs/jobs/每日更新.md \ + content/docs/jobs/秋招提前批.md + fi + + - name: 初始化主题子模块 + if: steps.changes.outputs.updated == 'true' + run: git submodule update --init --recursive + + - name: 安装 Hugo + if: steps.changes.outputs.updated == 'true' + uses: peaceiris/actions-hugo@v3 + with: + hugo-version: ${{ env.HUGO_VERSION }} + extended: true + + - name: 构建整站 + if: steps.changes.outputs.updated == 'true' + run: hugo --minify + + - name: 校验搜索索引收录 + if: steps.changes.outputs.updated == 'true' + run: python3 scripts/check_search_index.py --site public + + - name: 提交并推送招聘信息 + if: steps.changes.outputs.updated == 'true' + shell: bash + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -- \ + data/jobs/daily-updates.json \ + data/jobs/early-recruitment.json \ + content/docs/jobs/每日更新.md \ + content/docs/jobs/秋招提前批.md + git commit -m "更新招聘信息" + git push origin "HEAD:$DEFAULT_BRANCH" + + - name: 触发现有 Pages 发布工作流 + if: steps.changes.outputs.updated == 'true' + env: + GH_TOKEN: ${{ github.token }} + run: gh workflow run static.yml --ref "$DEFAULT_BRANCH" + + report_failure: + name: 报告同步失败 + needs: sync + if: ${{ always() && needs.sync.result == 'failure' }} + runs-on: ubuntu-latest + steps: + - name: 创建或更新故障 Issue + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + shell: bash + run: | + title='招聘信息同步失败' + printf -v body '招聘信息同步未通过完整性门槛,现有站点内容保持不变。\n\n运行:%s\n触发方式:%s\n提交:%s' \ + "$RUN_URL" "${{ github.event_name }}" "${{ github.sha }}" + issue_number="$(gh issue list --state open --search "$title in:title" --json number,title --jq '.[] | select(.title == "招聘信息同步失败") | .number' | head -n 1)" + if [[ -n "$issue_number" ]]; then + gh issue edit "$issue_number" --body "$body" + else + gh issue create --title "$title" --body "$body" + fi From 6c6501c950206c7791e3f5a381fd436f7546de68 Mon Sep 17 00:00:00 2001 From: Krypt0n123 <352600525@qq.com> Date: Wed, 19 Aug 2026 00:43:41 +0800 Subject: [PATCH 3/9] update --- .github/ISSUE_TEMPLATE/interview-question.yml | 76 ++++ .../interview-question-submission.yml | 151 +++++++ scripts/process_interview_question.py | 375 ++++++++++++++++++ skills/interview-question/SKILL.md | 75 ++++ 4 files changed, 677 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/interview-question.yml create mode 100644 .github/workflows/interview-question-submission.yml create mode 100755 scripts/process_interview_question.py create mode 100644 skills/interview-question/SKILL.md diff --git a/.github/ISSUE_TEMPLATE/interview-question.yml b/.github/ISSUE_TEMPLATE/interview-question.yml new file mode 100644 index 0000000..e4528bf --- /dev/null +++ b/.github/ISSUE_TEMPLATE/interview-question.yml @@ -0,0 +1,76 @@ +name: 📝 提交面经 +description: 提交一份已经用 Skill 格式化的完整面试记录 +title: "[面经] 请填写投稿人-公司-岗位-轮次" +labels: + - question-submission +body: + - type: markdown + attributes: + value: | + 感谢投稿!请先用仓库中的 [interview-question Skill](https://github.com/LeoninCS/GoClub/blob/main/skills/interview-question/SKILL.md) 整理整场面试,再把输出结果粘贴到下面。 + + 一次 Issue 只提交一场面试。为了避免外层代码块和正文代码块冲突,请使用 **四个反引号** 包裹整份 Markdown: + + ````markdown + --- + title: "xxx-字节跳动Golang一面" + category: "dachang" + difficulty: "hard" + tags: + - "Go" + - "MySQL" + --- + + # 字节跳动Golang + + 1. 第一个 PR 修复了什么问题? + + ## 参考答案(AI 生成) + + > 以下答案由 AI 生成,仅供面试复盘参考。 + + ### 1. 第一个 PR 修复了什么问题? + + 答:结合真实项目经历说明。 + ```` + + - type: textarea + id: standardized_markdown + attributes: + label: 标准化 Markdown + description: 只粘贴 Skill 输出的完整面经 Markdown,并用四个反引号代码块包裹。 + placeholder: | + ````markdown + --- + title: "xxx-字节跳动Golang一面" + category: "dachang" + difficulty: "hard" + tags: + - "Go" + - "MySQL" + --- + + # 字节跳动Golang + + 1. 第一个 PR 修复了什么问题? + + ## 参考答案(AI 生成) + + > 以下答案由 AI 生成,仅供面试复盘参考。 + + ### 1. 第一个 PR 修复了什么问题? + + 答:结合真实项目经历说明。 + ```` + validations: + required: true + + - type: checkboxes + id: format_confirmation + attributes: + label: 投稿确认 + options: + - label: 我已通过 interview-question Skill 格式化。 + required: true + - label: 我确认内容不包含保密信息,不侵犯他人版权,且没有恶意链接或脚本。 + required: true diff --git a/.github/workflows/interview-question-submission.yml b/.github/workflows/interview-question-submission.yml new file mode 100644 index 0000000..9332b97 --- /dev/null +++ b/.github/workflows/interview-question-submission.yml @@ -0,0 +1,151 @@ +name: 处理面经投稿 + +on: + issues: + types: + - opened + +permissions: + contents: write + issues: write + pull-requests: write + +concurrency: + group: interview-question-${{ github.event.issue.number || github.run_id }} + cancel-in-progress: false + +env: + HUGO_VERSION: 0.159.0 + CONTENT_ROOT: content/docs/interview + +jobs: + create-pr: + name: 校验面经并生成 PR + runs-on: ubuntu-latest + timeout-minutes: 15 + if: > + contains(github.event.issue.labels.*.name, 'question-submission') || + contains(github.event.issue.body, '### 标准化 Markdown') + steps: + - name: 检出默认分支 + uses: actions/checkout@v4 + with: + ref: ${{ github.event.repository.default_branch || 'main' }} + submodules: false + fetch-depth: 1 + + - name: 补齐投稿标签 + env: + GH_TOKEN: ${{ github.token }} + ISSUE_NUMBER: ${{ github.event.issue.number }} + run: | + gh label create question-submission \ + --color 0E8A16 \ + --description "面经自动投稿" || true + if [[ -n "$ISSUE_NUMBER" ]]; then + gh issue edit "$ISSUE_NUMBER" --add-label question-submission + fi + + - name: 提取并校验投稿 + id: import + continue-on-error: true + env: + ISSUE_NUMBER: ${{ github.event.issue.number }} + run: | + python3 scripts/process_interview_question.py \ + --payload "$GITHUB_EVENT_PATH" \ + --content-root "$CONTENT_ROOT" \ + --write + + - name: 标记格式错误并通知投稿者 + if: steps.import.outcome == 'failure' + env: + GH_TOKEN: ${{ github.token }} + ISSUE_NUMBER: ${{ github.event.issue.number }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + gh label create invalid-format --color D73A4A --description "面经投稿格式不正确" || true + gh issue edit "$ISSUE_NUMBER" --add-label invalid-format + gh issue comment "$ISSUE_NUMBER" --body "格式校验失败,请检查是否完整使用了 [interview-question Skill](https://github.com/LeoninCS/GoClub/blob/main/skills/interview-question/SKILL.md) 输出的整份面经标准格式。构建日志:$RUN_URL" + exit 1 + + - name: 初始化主题子模块 + if: steps.import.outcome == 'success' + run: git submodule update --init --recursive + + - name: 安装 Hugo + if: steps.import.outcome == 'success' + uses: peaceiris/actions-hugo@v3 + with: + hugo-version: ${{ env.HUGO_VERSION }} + extended: true + + - name: 校验 URL 与站点构建 + if: steps.import.outcome == 'success' + run: | + python3 scripts/check_slugs.py + hugo --minify + python3 scripts/check_search_index.py --site public + + - name: 检查待提交范围 + if: steps.import.outcome == 'success' + env: + CONTENT_PATH: ${{ steps.import.outputs.content_path }} + run: | + git add -- "$CONTENT_PATH" + changed="$(git diff --cached --name-only)" + if [[ "$changed" != "$CONTENT_PATH" ]]; then + echo "本次自动化只能提交一个面经文件,实际待提交文件:" + echo "$changed" + exit 1 + fi + git diff --cached --check + + - name: 创建分支、提交并生成 PR + if: steps.import.outcome == 'success' + env: + GH_TOKEN: ${{ github.token }} + BRANCH: ${{ steps.import.outputs.target_branch }} + CONTENT_PATH: ${{ steps.import.outputs.content_path }} + PR_TITLE: ${{ steps.import.outputs.pr_title }} + ISSUE_NUMBER: ${{ steps.import.outputs.issue_number }} + SUBMITTER: ${{ steps.import.outputs.submitter }} + run: | + if git ls-remote --exit-code --heads origin "$BRANCH" >/dev/null; then + existing_pr="$(gh pr list --head "$BRANCH" --state open --json number --jq '.[0].number // empty')" + if [[ -n "$existing_pr" ]]; then + gh issue comment "$ISSUE_NUMBER" --body "✅ 该投稿已生成 PR #$existing_pr,管理员审核通过后将合并上线。" + exit 0 + fi + echo "分支 $BRANCH 已存在但没有打开的 PR,请管理员删除旧分支后重新触发。" >&2 + exit 1 + fi + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git switch -c "$BRANCH" + git commit -m "面经收录:Issue #$ISSUE_NUMBER" + git push -u origin "$BRANCH" + + pr_url="$(gh pr create \ + --base "${{ github.event.repository.default_branch || 'main' }}" \ + --head "$BRANCH" \ + --title "$PR_TITLE" \ + --body "由 Issue #$ISSUE_NUMBER 自动生成,感谢贡献者 @$SUBMITTER。 + + Closes #$ISSUE_NUMBER + + - 自动化来源:Issue Form + - 自动校验:Frontmatter、内容安全、URL slug、Hugo 构建 + - 管理员审核合并后会自动发布上线")" + + gh issue comment "$ISSUE_NUMBER" --body "✅ 已为你自动生成 $pr_url,管理员审核通过后将合并上线。" + + - name: 通知自动化失败 + if: failure() && steps.import.outcome == 'success' + env: + GH_TOKEN: ${{ github.token }} + ISSUE_NUMBER: ${{ github.event.issue.number }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + gh issue comment "$ISSUE_NUMBER" --body "面经内容通过了基础格式校验,但自动生成 PR 前的站点验证或 Git 操作失败。管理员会检查构建日志:$RUN_URL" diff --git a/scripts/process_interview_question.py b/scripts/process_interview_question.py new file mode 100755 index 0000000..9b0ef8a --- /dev/null +++ b/scripts/process_interview_question.py @@ -0,0 +1,375 @@ +#!/usr/bin/env python3 +"""Validate an Issue Form interview submission and create a Hugo page.""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import sys +import unicodedata +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +ALLOWED_CATEGORIES = {"dachang", "zhongchang", "xiaochang"} +ALLOWED_DIFFICULTIES = {"easy", "medium", "hard"} +ALLOWED_METADATA_KEYS = {"title", "category", "difficulty", "tags", "slug"} +MAX_MARKDOWN_LENGTH = 60_000 +MAX_TITLE_LENGTH = 80 +MAX_SLUG_LENGTH = 72 +SLUG_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") + + +class SubmissionError(ValueError): + """A user-facing validation error for an interview submission.""" + + +@dataclass(frozen=True) +class ParsedSubmission: + title: str + category: str + difficulty: str + tags: tuple[str, ...] + submitted_slug: str | None + body_markdown: str + slug: str + + +def read_payload(path: Path) -> dict[str, Any]: + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError) as error: + raise SubmissionError(f"无法读取 GitHub Issue 事件文件:{error}") from error + + issue = payload.get("issue") + if not isinstance(issue, dict): + raise SubmissionError("事件Payload中缺少 Issue 信息。") + if not isinstance(issue.get("body"), str): + raise SubmissionError("Issue 正文为空或格式不正确。") + labels = issue.get("labels", []) + label_names = {label.get("name") for label in labels if isinstance(label, dict)} + if "question-submission" not in label_names: + raise SubmissionError("这个 Issue 没有 question-submission 标签。") + if not isinstance(issue.get("number"), int): + raise SubmissionError("Issue 编号缺失或格式不正确。") + if not isinstance(issue.get("user"), dict) or not issue["user"].get("login"): + raise SubmissionError("Issue 提交者信息缺失。") + return payload + + +def confirm_submission_rules(issue_body: str) -> None: + if not re.search( + r"^[-*]\s+\[[xX]\]\s+我已通过 interview-question Skill 格式化", + issue_body, + re.MULTILINE, + ): + raise SubmissionError("请先勾选“我已通过 interview-question Skill 格式化”。") + if not re.search( + r"^[-*]\s+\[[xX]\]\s+我确认内容不包含保密信息", + issue_body, + re.MULTILINE, + ): + raise SubmissionError("请先勾选内容安全和版权确认项。") + + +def extract_fenced_markdown(issue_body: str) -> str: + """Extract the outer fenced block containing standardized interview Markdown.""" + if len(issue_body) > MAX_MARKDOWN_LENGTH + 8_000: + raise SubmissionError("Issue 内容过长,请只提交一场面试。") + + candidates: list[str] = [] + lines = issue_body.splitlines() + index = 0 + while index < len(lines): + opening = re.fullmatch(r"(`{3,})[ \t]*(?:markdown|md|yaml)?[ \t]*", lines[index]) + if opening is None: + index += 1 + continue + + fence_length = len(opening.group(1)) + content: list[str] = [] + index += 1 + while index < len(lines): + if re.fullmatch(r"`{%d,}[ \t]*" % fence_length, lines[index]): + candidates.append("\n".join(content)) + break + content.append(lines[index]) + index += 1 + index += 1 + + for candidate in candidates: + if candidate.startswith("---\n"): + if len(candidate) > MAX_MARKDOWN_LENGTH: + raise SubmissionError("标准化 Markdown 超过 60,000 字符,请精简内容。") + return candidate + "\n" + + raise SubmissionError( + "没有找到包含 YAML Frontmatter 的 Markdown 代码块。" + "请把 Skill 输出的完整面经放在四个反引号代码块内。" + ) + + +def split_front_matter(markdown: str) -> tuple[str, str]: + lines = markdown.splitlines() + if not lines or lines[0].strip() != "---": + raise SubmissionError("Markdown 开头必须是 YAML Frontmatter 分隔线 ---。") + + for index, line in enumerate(lines[1:], start=1): + if line.strip() == "---": + front_matter = "\n".join(lines[1:index]) + body = "\n".join(lines[index + 1 :]).strip("\r\n") + return front_matter, body + + raise SubmissionError("YAML Frontmatter 没有结束分隔线 ---。") + + +def decode_scalar(value: str, field: str) -> str: + value = value.strip() + if not value: + raise SubmissionError(f"{field} 不能为空。") + try: + if value.startswith('"') and value.endswith('"'): + decoded = json.loads(value) + elif value.startswith("'") and value.endswith("'"): + decoded = value[1:-1].replace("''", "'") + else: + decoded = value + except json.JSONDecodeError as error: + raise SubmissionError(f"{field} 的引号格式不正确。") from error + if not isinstance(decoded, str) or not decoded.strip(): + raise SubmissionError(f"{field} 不能为空。") + return decoded.strip() + + +def parse_inline_tags(value: str) -> list[str]: + tags = decode_scalar(value, "tags") + if tags.startswith("[") and tags.endswith("]"): + try: + decoded = json.loads(tags) + except json.JSONDecodeError as error: + raise SubmissionError("tags 必须是 YAML 列表。") from error + if not isinstance(decoded, list) or any(not isinstance(tag, str) for tag in decoded): + raise SubmissionError("tags 中每一项都必须是字符串。") + return decoded + raise SubmissionError("tags 必须使用列表格式,例如 [\"Go\", \"MySQL\"] 或多行列表。") + + +def parse_front_matter(front_matter: str) -> dict[str, Any]: + result: dict[str, Any] = {} + current_key: str | None = None + for raw_line in front_matter.splitlines(): + if not raw_line.strip(): + current_key = None + continue + if raw_line.lstrip().startswith("#"): + raise SubmissionError("Frontmatter 中不支持注释,请删除 # 注释行。") + + match = re.fullmatch(r"([A-Za-z][A-Za-z0-9_-]*):(.*)", raw_line) + if match: + key = match.group(1) + if key in result: + raise SubmissionError(f"Frontmatter 字段 {key} 重复。") + if key not in ALLOWED_METADATA_KEYS: + raise SubmissionError( + f"Frontmatter 不支持字段 {key}。" + "只能包含 title、category、difficulty、tags 和可选 slug。" + ) + value = match.group(2).strip() + if value: + result[key] = parse_inline_tags(value) if key == "tags" else decode_scalar(value, key) + current_key = None + else: + result[key] = [] + current_key = key + continue + + item = re.fullmatch(r"[ \t]+-[ \t]+(.+)", raw_line) + if item and current_key == "tags": + result["tags"].append(decode_scalar(item.group(1), "tags 项")) + continue + + raise SubmissionError(f"Frontmatter 第 {raw_line!r} 无法解析。") + + if "tags" in result and not isinstance(result["tags"], list): + raise SubmissionError("tags 必须是列表。") + return result + + +def validate_metadata(metadata: dict[str, Any], issue_number: int, category_dir: Path) -> ParsedSubmission: + required = {"title", "category", "difficulty", "tags"} + missing = sorted(required - set(metadata)) + if missing: + raise SubmissionError(f"Frontmatter 缺少必要字段:{', '.join(missing)}。") + + title = metadata["title"] + category = metadata["category"] + difficulty = metadata["difficulty"] + tags = metadata["tags"] + submitted_slug = metadata.get("slug") + + if not isinstance(title, str) or not (5 <= len(title) <= MAX_TITLE_LENGTH): + raise SubmissionError("title 长度必须在 5 到 80 个字符之间。") + if title != title.strip() or re.search(r"[\r\n\t]", title): + raise SubmissionError("title 首尾不能有空格,也不能包含换行或制表符。") + + if category not in ALLOWED_CATEGORIES: + allowed = "、".join(sorted(ALLOWED_CATEGORIES)) + raise SubmissionError(f"category 必须是以下之一:{allowed}。") + + if difficulty not in ALLOWED_DIFFICULTIES: + raise SubmissionError("difficulty 只能是 easy、medium 或 hard。") + + if not isinstance(tags, list) or not tags: + raise SubmissionError("tags 至少需要一个标签。") + if len(tags) > 6: + raise SubmissionError("tags 最多只能有 6 个。") + if any(not isinstance(tag, str) for tag in tags): + raise SubmissionError("tags 中每一项都必须是字符串。") + normalized_tags = [tag.strip() for tag in tags] + if any(not (2 <= len(tag) <= 24) for tag in normalized_tags): + raise SubmissionError("每个 tag 长度必须在 2 到 24 个字符之间。") + if len(set(normalized_tags)) != len(normalized_tags): + raise SubmissionError("tags 不能重复。") + + if submitted_slug is not None: + if not isinstance(submitted_slug, str) or not SLUG_RE.match(submitted_slug): + raise SubmissionError("slug 只能包含小写字母、数字和连字符,且不能以连字符开头或结尾。") + if len(submitted_slug) > MAX_SLUG_LENGTH: + raise SubmissionError("slug 不能超过 72 个字符。") + if (category_dir / f"{submitted_slug}.md").exists(): + raise SubmissionError(f"目标文件 {submitted_slug}.md 已存在,请更换 slug。") + slug = submitted_slug + else: + normalized_title = unicodedata.normalize("NFKD", title).encode("ascii", "ignore").decode("ascii") + base = re.sub(r"[^A-Za-z0-9]+", "-", normalized_title).strip("-").lower() + base = re.sub(r"-{2,}", "-", base)[:MAX_SLUG_LENGTH].rstrip("-") + base = base if len(base) >= 2 else f"interview-issue-{issue_number}" + slug = base + if (category_dir / f"{slug}.md").exists(): + slug = f"{base}-issue-{issue_number}" + if not SLUG_RE.match(slug) or len(slug) > MAX_SLUG_LENGTH: + raise SubmissionError("无法生成合法的 ASCII slug,请在 Frontmatter 提供 slug 字段。") + + return ParsedSubmission( + title=title, + category=category, + difficulty=difficulty, + tags=tuple(normalized_tags), + submitted_slug=submitted_slug, + body_markdown="", + slug=slug, + ) + + +def validate_content(markdown: str, parsed: ParsedSubmission) -> ParsedSubmission: + """Validate only page safety and non-emptiness; do not enforce article structure.""" + if not markdown.strip(): + raise SubmissionError("正文不能为空。") + + forbidden_patterns = { + "Hugo shortcode": r"\{\{[<%]", + "script 标签": r"<\s*script\b", + "iframe 标签": r"<\s*iframe\b", + "style 标签": r"<\s*style\b", + "内联事件属性": r"\bon[a-z]+\s*=", + "javascript 链接": r"(?i)javascript[ \t]*:", + } + for label, pattern in forbidden_patterns.items(): + if re.search(pattern, markdown): + raise SubmissionError(f"内容不允许包含{label}。") + + return ParsedSubmission( + title=parsed.title, + category=parsed.category, + difficulty=parsed.difficulty, + tags=parsed.tags, + submitted_slug=parsed.submitted_slug, + body_markdown=markdown.strip() + "\n", + slug=parsed.slug, + ) + + +def yaml_quote(value: str) -> str: + return json.dumps(value, ensure_ascii=False) + + +def render_page(submission: ParsedSubmission, issue_number: int) -> str: + front_matter = [ + "---", + f"title: {yaml_quote(submission.title)}", + f"category: {yaml_quote(submission.category)}", + f"difficulty: {yaml_quote(submission.difficulty)}", + "tags:", + *(f" - {yaml_quote(tag)}" for tag in submission.tags), + f"weight: {issue_number}", + f"slug: {yaml_quote(submission.slug)}", + "---", + ] + return "\n".join(front_matter) + "\n\n" + submission.body_markdown + + +def append_github_output(values: dict[str, str | int]) -> None: + output_file = os.environ.get("GITHUB_OUTPUT") + if not output_file: + return + with open(output_file, "a", encoding="utf-8") as handler: + for key, value in values.items(): + handler.write(f"{key}={value}\n") + + +def process(payload_path: Path, content_root: Path, write: bool) -> int: + try: + payload = read_payload(payload_path) + issue = payload["issue"] + confirm_submission_rules(issue["body"]) + source_markdown = extract_fenced_markdown(issue["body"]) + front_matter, body = split_front_matter(source_markdown) + metadata = parse_front_matter(front_matter) + + issue_number = issue["number"] + category = metadata.get("category") + if category not in ALLOWED_CATEGORIES: + allowed = "、".join(sorted(ALLOWED_CATEGORIES)) + raise SubmissionError(f"category 必须是以下之一:{allowed}。") + category_dir = content_root / str(category) + category_dir.mkdir(parents=True, exist_ok=True) + parsed = validate_metadata(metadata, issue_number, category_dir) + parsed = validate_content(body, parsed) + + relative_path = category_dir / f"{parsed.slug}.md" + if relative_path.exists(): + raise SubmissionError("目标文件已存在,请更换 slug。") + page = render_page(parsed, issue_number) + if write: + relative_path.write_text(page, encoding="utf-8", newline="\n") + + append_github_output( + { + "content_path": relative_path.as_posix(), + "page_title": parsed.title, + "page_slug": parsed.slug, + "target_branch": f"bot/issue-{issue_number}", + "pr_title": f"面经收录:{parsed.title} (#{issue_number})", + "submitter": issue["user"]["login"], + "issue_number": issue_number, + } + ) + print(f"校验通过:{relative_path}") + return 0 + except SubmissionError as error: + print(f"格式校验失败:{error}", file=sys.stderr) + return 1 + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--payload", required=True, type=Path, help="GitHub issue event JSON") + parser.add_argument("--content-root", type=Path, default=Path("content/docs/interview")) + parser.add_argument("--write", action="store_true", help="写入生成的 Markdown 文件") + return parser.parse_args() + + +if __name__ == "__main__": + raise SystemExit(process(parse_args().payload, parse_args().content_root, parse_args().write)) diff --git a/skills/interview-question/SKILL.md b/skills/interview-question/SKILL.md new file mode 100644 index 0000000..0abe4dd --- /dev/null +++ b/skills/interview-question/SKILL.md @@ -0,0 +1,75 @@ +--- +name: interview-question +description: Format raw Go backend interview materials into standardized GoClub Markdown. +--- + +# GoClub Interview Formatting Skill + +Your task is to convert a complete interview record, transcript, notes, chat log, or draft answers into a GoClub interview page. Process one interview session at a time. Do not split one session into separate question files. + +Return only the standardized Markdown below. Do not add explanations, greetings, surrounding quotes, or an extra outer code fence. + +## Required output shape + +```markdown +--- +title: "contributor-company-role-round" +category: "zhongchang" +difficulty: "medium" +tags: + - "Go" + - "MySQL" +slug: "contributor-company-role-round" +--- + +# Company and role title + +1. First interview question +2. Second interview question + +## 参考答案(AI 生成) + +> 以下答案由 AI 生成,仅供面试复盘参考。 + +### 1. First interview question + +答:Start with a clear conclusion, then explain the key mechanism, boundary conditions, and likely follow-up questions. + +### 2. Second interview question + +答:Start with a clear conclusion, then explain the key mechanism, boundary conditions, and likely follow-up questions. +``` + +## Metadata rules + +- `title`: 5–80 characters. Use the pattern `contributor-company-role-round`, for example `krypton-深信服Golang一面`. +- `category`: the company-size section. It must be one of: + - `dachang` + - `zhongchang` + - `xiaochang` +- `difficulty`: the overall difficulty of the interview. Use only `easy`, `medium`, or `hard`. +- `tags`: 1–6 tags, each 2–24 characters. Prefer major technical topics such as Go, MySQL, Redis, distributed systems, and Kubernetes. +- `slug`: an ASCII slug derived from the contributor, company, role, and round. Use only lowercase letters, digits, and hyphens. Keep it under 48 characters when possible, for example `krypton-sangfor-golang-1`. Do not use Chinese characters, underscores, or consecutive hyphens. + +## Content rules + +- Write page content in Simplified Chinese unless the user explicitly requests another language. +- Start the body with one concise H1 describing the company and role. Do not repeat the contributor nickname there. +- Preserve every meaningful interview question. Remove greetings, duplicates, and conversation unrelated to the interview. +- Keep numbered questions in Arabic numeral order. Clarify unclear spoken wording, but do not change the intended meaning. +- Keep the `## 参考答案(AI 生成)` heading and the exact AI-generated notice shown above. +- Give one numbered H3 answer heading for each question. Start each answer with `答:`, state the conclusion first, and then add principles, scenarios, trade-offs, or commands as appropriate. +- For project questions, organize an answer around the user's actual experience. Do not fabricate personal experience. +- Put code in triple-backtick code blocks. When submitting through GitHub, wrap the entire Markdown output in a four-backtick code block. +- Do not output Hugo shortcodes such as `{{< ... >}}` or `{{% ... %}}`. +- Do not output `