diff --git a/.github/ISSUE_TEMPLATE/interview-question.yml b/.github/ISSUE_TEMPLATE/interview-question.yml index 5969d29..3f98f00 100644 --- a/.github/ISSUE_TEMPLATE/interview-question.yml +++ b/.github/ISSUE_TEMPLATE/interview-question.yml @@ -1,6 +1,6 @@ name: 📝 提交面经 -description: 提交一份已经用 Skill 格式化的完整面试记录 -title: "[面经] 请填写投稿人-公司-岗位-轮次" +description: 直接提交面试记录 +title: "[面经]投稿人-公司-岗位-几面" labels: - question-submission body: @@ -18,40 +18,27 @@ body: - type: markdown attributes: value: | - 感谢投稿!请先用仓库中的 [interview-question Skill](https://github.com/LeoninCS/GoClub/blob/main/skills/interview-question/SKILL.md) 整理整场面试,再把输出结果粘贴到下面。 - ai生成不一定准确可以进行微调, title可以填上自己的名字, 方便辨别 (匿名也可以!) - ⚠️ **整份 Markdown 必须用四个反引号(````)包裹**, 否则正文中的代码块会导致解析失败、投稿无法收录。 + 感谢投稿! + 请填写 issue 标题,网站会根据这个标题进行展示(可以匿名!) + 直接粘贴完整面试题目即可 - type: textarea - id: standardized_markdown + id: interview_content attributes: - label: 标准化 Markdown - description: 只粘贴 Skill 输出的完整面经 Markdown,并用四个反引号代码块包裹。 - value: | - ```` - --- - title: "xxx-字节跳动Golang一面" - difficulty: "hard" - --- - - # 字节跳动Golang - 1. PR 修复了什么问题? - ## 参考答案(AI 生成) - - > 以下答案由 AI 生成,仅供面试复盘参考。 - ### 1. PR 修复了什么问题? - 答:结合真实项目经历说明。 - ```` + label: 面试内容 + description: 直接粘贴完整 Markdown。 + placeholder: | + 1. why + 2. where + 3. what validations: required: true - type: checkboxes - id: format_confirmation + id: submission_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 index 1120dbe..fdcde5f 100644 --- a/.github/workflows/interview-question-submission.yml +++ b/.github/workflows/interview-question-submission.yml @@ -32,6 +32,10 @@ jobs: ref: ${{ github.event.repository.default_branch || 'main' }} submodules: false fetch-depth: 1 + sparse-checkout: | + scripts + content/docs/interview + show-progress: false - name: 感谢投稿并补齐标签 env: diff --git a/scripts/process_interview_question.py b/scripts/process_interview_question.py index 28787b0..5be6f28 100755 --- a/scripts/process_interview_question.py +++ b/scripts/process_interview_question.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Convert an Issue Form interview submission into a Hugo page.""" +"""Convert a GitHub Issue interview submission into a Hugo page.""" from __future__ import annotations @@ -8,33 +8,19 @@ 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"} CATEGORY_LABELS = {"大厂": "dachang", "中厂": "zhongchang", "小厂": "xiaochang"} -ALLOWED_DIFFICULTIES = {"easy", "medium", "hard"} 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 conversion error for an unusable GitHub Issue event.""" -@dataclass(frozen=True) -class ParsedSubmission: - title: str - category: str - difficulty: str - tags: tuple[str, ...] - body_markdown: str - slug: str - - def read_payload(path: Path) -> dict[str, Any]: try: payload = json.loads(path.read_text(encoding="utf-8")) @@ -51,33 +37,10 @@ def read_payload(path: Path) -> dict[str, Any]: return payload -def extract_fenced_blocks(issue_body: str) -> list[str]: - blocks: list[str] = [] - lines = issue_body.splitlines() - index = 0 - while index < len(lines): - opening = re.fullmatch(r"(`{3,})[ \t]*(?:markdown|md)?[ \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]): - blocks.append("\n".join(content).strip("\n")) - break - content.append(lines[index]) - index += 1 - index += 1 - return blocks - - def extract_selected_category(issue_body: str) -> str | None: """Read the company-size choice from the Issue Form.""" match = re.search( - r"^###[^\n]*\n+\s*(大厂|中厂|小厂)\s*$", + r"^###\s*请选择面经要收录的目录\s*$\n+\s*(大厂|中厂|小厂)\s*$", issue_body, flags=re.MULTILINE, ) @@ -86,11 +49,24 @@ def extract_selected_category(issue_body: str) -> str | None: return CATEGORY_LABELS[match.group(1)] +def extract_textarea_content(issue_body: str, field_label: str) -> str | None: + """Read an Issue Form textarea section while preserving Markdown exactly.""" + match = re.search( + rf"^###\s*{re.escape(field_label)}\s*$\n(.*?)(?=^###\s|\Z)", + issue_body, + flags=re.MULTILINE | re.DOTALL, + ) + if not match: + return None + content = match.group(1).strip() + return content or None + + def extract_submission_content(issue_body: str) -> str: """Get the submitted Markdown without rejecting imperfect formatting.""" - blocks = extract_fenced_blocks(issue_body) - if blocks: - return max(blocks, key=len) + textarea_content = extract_textarea_content(issue_body, "面试内容") + if textarea_content is not None: + return textarea_content match = re.search( r"^###\s+标准化 Markdown\s*$\n(.*?)(?=^###\s+|\Z)", @@ -100,68 +76,24 @@ def extract_submission_content(issue_body: str) -> str: if match and match.group(1).strip(): return match.group(1).strip() - lines = [] - for line in issue_body.splitlines(): - if line.startswith("### ") or re.match(r"^[-*]\s+\[[ xX]\]", line): - continue - lines.append(line) - return "\n".join(lines).strip() - - -def split_front_matter(markdown: str) -> tuple[dict[str, Any], str]: - lines = markdown.splitlines() - if not lines or lines[0].strip() != "---": - return {}, markdown - - 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") - metadata = parse_metadata(front_matter) - return metadata, body - return {}, markdown - - -def decode_scalar(value: str, field: str) -> str: - value = value.strip() - if not value: - raise ValueError(f"{field} is empty") - if value.startswith('"') and value.endswith('"'): - value = json.loads(value) - elif value.startswith("'") and value.endswith("'"): - value = value[1:-1].replace("''", "'") - if not isinstance(value, str) or not value.strip(): - raise ValueError(f"{field} is empty") - return value.strip() - - -def parse_metadata(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(): - continue - match = re.fullmatch(r"([A-Za-z][A-Za-z0-9_-]*):(.*)", raw_line) - if match: - key, value = match.group(1), match.group(2).strip() - if value: - result[key] = decode_scalar(value, key) if key != "tags" else [decode_scalar(value, "tags")] - else: - result[key] = [] - current_key = key - continue - item = re.fullmatch(r"[ \t]+-[ \t]+(.+)", raw_line) - if item and current_key == "tags": - result[current_key].append(decode_scalar(item.group(1), "tags item")) - continue - raise ValueError(f"unsupported front matter line: {raw_line!r}") - return result - - -def normalize_title(metadata: dict[str, Any], issue_title: str, issue_number: int) -> str: - title = metadata.get("title") if isinstance(metadata.get("title"), str) else "" - if not title.strip(): - title = re.sub(r"^\s*\[面经\]\s*", "", issue_title).strip() + return issue_body.strip() + + +def strip_front_matter(markdown: str) -> str: + """Remove a leading front-matter block from legacy submissions.""" + if not markdown.startswith("---\n"): + return markdown + end = markdown.find("\n---", 3) + if end == -1: + return markdown + front_matter = markdown[4:end] + if not re.search(r"^title:", front_matter, flags=re.MULTILINE): + return markdown + return markdown[end + 4 :].lstrip("\n") + + +def normalize_title(issue_title: str, issue_number: int) -> str: + title = re.sub(r"^\s*\[面经\]\s*", "", issue_title).strip() title = " ".join(title.split()) if len(title) > MAX_TITLE_LENGTH: title = title[:MAX_TITLE_LENGTH].rstrip() @@ -170,98 +102,8 @@ def normalize_title(metadata: dict[str, Any], issue_title: str, issue_number: in return title -def normalize_tags(metadata: dict[str, Any], body: str) -> tuple[str, ...]: - raw_tags = metadata.get("tags", []) - if isinstance(raw_tags, str): - raw_tags = [raw_tags] - if not isinstance(raw_tags, list): - raw_tags = [] - - tags = [] - for tag in raw_tags: - if not isinstance(tag, str): - continue - tag = tag.strip() - if 2 <= len(tag) <= 24 and tag not in tags: - tags.append(tag) - - candidates = ( - ("Go", ("go ", "golang", "goroutine")), - ("MySQL", ("mysql",)), - ("Redis", ("redis",)), - ("Kubernetes", ("kubernetes", "k8s")), - ("分布式", ("分布式",)), - ) - lowered = body.lower() - for tag, keywords in candidates: - if len(tags) >= 6: - break - if tag not in tags and any(keyword in lowered for keyword in keywords): - tags.append(tag) - if not tags: - tags = ["Go"] - return tuple(tags[:6]) - - -def normalize_slug(metadata: dict[str, Any], title: str, issue_number: int, category_dir: Path) -> str: - submitted = metadata.get("slug") if isinstance(metadata.get("slug"), str) else "" - - def ascii_slug(value: str) -> str: - normalized = unicodedata.normalize("NFKD", value).encode("ascii", "ignore").decode("ascii") - slug = re.sub(r"[^A-Za-z0-9]+", "-", normalized).strip("-").lower() - return re.sub(r"-{2,}", "-", slug)[:MAX_SLUG_LENGTH].rstrip("-") - - slug = ascii_slug(submitted) or ascii_slug(title) or f"interview-issue-{issue_number}" - if not SLUG_RE.match(slug): - slug = f"interview-issue-{issue_number}" - if (category_dir / f"{slug}.md").exists(): - slug = f"{slug[: MAX_SLUG_LENGTH - len(str(issue_number)) - 7]}-issue-{issue_number}" - return slug - - -def normalize_body(body: str, title: str) -> str: - lines = body.strip().splitlines() - if not lines: - lines = [title, "", "待确认。"] - - if lines and not lines[0].lstrip().startswith("#"): - lines[0] = f"# {lines[0].strip() or title}" - - output: list[str] = [] - in_answers = False - for line in lines: - stripped = line.strip() - if stripped == "参考答案(AI 生成)": - output.append("## 参考答案(AI 生成)") - in_answers = True - continue - if in_answers and re.match(r"^\d+[.、]\s*", stripped): - output.append(re.sub(r"^(\d+)[.、]\s*", r"### \1. ", stripped)) - continue - if in_answers and stripped == "以下答案由 AI 生成,仅供面试复盘参考。": - output.append("> 以下答案由 AI 生成,仅供面试复盘参考。") - continue - output.append(line.rstrip()) - return "\n".join(output).strip() + "\n" - - -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 page_filename(issue_number: int) -> str: + return f"issue-{issue_number}.md" def append_github_output(values: dict[str, str | int]) -> None: @@ -277,42 +119,31 @@ def process(payload_path: Path, content_root: Path, write: bool) -> int: try: payload = read_payload(payload_path) issue = payload["issue"] - source_markdown = extract_submission_content(issue["body"]) - try: - metadata, body = split_front_matter(source_markdown) - except (ValueError, json.JSONDecodeError): - metadata, body = {}, source_markdown - issue_number = issue["number"] + category = extract_selected_category(issue["body"]) - if category is None: - category = metadata.get("category") if isinstance(metadata.get("category"), str) else "" if category not in ALLOWED_CATEGORIES: category = "zhongchang" category_dir = content_root / category category_dir.mkdir(parents=True, exist_ok=True) - title = normalize_title(metadata, issue.get("title", ""), issue_number) - difficulty = metadata.get("difficulty") if isinstance(metadata.get("difficulty"), str) else "" - if difficulty not in ALLOWED_DIFFICULTIES: - difficulty = "medium" - tags = normalize_tags(metadata, source_markdown) - slug = normalize_slug(metadata, title, issue_number, category_dir) - body = normalize_body(body, title) - submission = ParsedSubmission(title, category, difficulty, tags, body, slug) - - relative_path = category_dir / f"{submission.slug}.md" - page = render_page(submission, issue_number) + title = normalize_title(issue.get("title", ""), issue_number) + body = strip_front_matter(extract_submission_content(issue["body"])).strip() + if not body: + body = "待确认。" + + relative_path = category_dir / page_filename(issue_number) + page = f'---\ntitle: {json.dumps(title, ensure_ascii=False)}\n---\n\n{body}\n' + if write: relative_path.write_text(page, encoding="utf-8", newline="\n") append_github_output( { "content_path": relative_path.as_posix(), - "page_title": submission.title, - "page_slug": submission.slug, + "page_title": title, "target_branch": f"bot/issue-{issue_number}", - "pr_title": f"面经收录:{submission.title} (#{issue_number})", + "pr_title": f"面经收录:{title} (#{issue_number})", "submitter": issue["user"]["login"], "issue_number": issue_number, } diff --git a/skills/interview-question/SKILL.md b/skills/interview-question/SKILL.md deleted file mode 100644 index 04e9c65..0000000 --- a/skills/interview-question/SKILL.md +++ /dev/null @@ -1,59 +0,0 @@ ---- -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" ---- - -# 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 `xxx-深信服Golang一面`. - -## 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 invent source-code versions, performance numbers, interview results, or company information. Use “待确认” when reliable information is unavailable. -- If the user provides questions without answers, generate conservative answers in the required structure. If a reliable answer is impossible, write “待确认” rather than inventing details. - -## Editing procedure - -1. Determine whether all material belongs to one interview session. If it contains multiple sessions, tell the user to process each session separately. -2. Clean and organize the question list without changing the meaning. -3. Write cautious, verifiable reference answers; remind users to revise project answers based on their real experience. -4. Return the complete standardized Markdown only.