From d42161ec520b2524995de1854241e38726b2a779 Mon Sep 17 00:00:00 2001 From: Funkierry <105119784+Funkierry@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:35:31 +1100 Subject: [PATCH] Add tracker cursor pages and tested commit-tagged deployment images --- .github/workflows/jobscout-deploy.yml | 76 ++- README.md | 4 + backend/app/application_tracker/AGENTS.md | 5 + .../app/application_tracker/email/store.py | 35 +- backend/app/application_tracker/pagination.py | 77 +++ backend/app/application_tracker/store.py | 5 + backend/app/gateway/routers/jobscout.py | 9 +- backend/tests/test_jobscout_deployment.py | 44 ++ backend/tests/test_jobscout_pagination.py | 93 +++ deploy/jobscout/env.example | 6 + deploy/jobscout/gateway.Dockerfile | 5 + .../jobscout/gateway.Dockerfile.dockerignore | 5 + deploy/jobscout/web.Dockerfile | 4 + deploy/jobscout/web.Dockerfile.dockerignore | 13 + docker/docker-compose.jobscout-images.yaml | 18 + docker/nginx/jobscout.conf | 1 + docs/jobscout/deployment.md | 31 +- docs/jobscout/reliability.md | 7 +- jobscout-web/AGENTS.md | 5 + jobscout-web/README.md | 9 + jobscout-web/api-client.js | 56 ++ jobscout-web/app.js | 511 ++-------------- jobscout-web/core.js | 565 ++++++++++++++++++ jobscout-web/index.html | 8 + jobscout-web/style.css | 3 + jobscout-web/test_async_state.js | 9 +- jobscout-web/test_concurrency.py | 57 +- jobscout-web/test_deployment.py | 17 +- jobscout-web/test_history_scroll.py | 214 ++++++- jobscout-web/test_link_guard.js | 2 +- jobscout-web/test_modules.js | 72 +++ jobscout-web/test_pure.js | 1 + jobscout-web/test_ui.py | 54 +- jobscout-web/tracker-state.js | 61 ++ scripts/AGENTS.md | 5 + scripts/jobscout.py | 61 +- 36 files changed, 1622 insertions(+), 526 deletions(-) create mode 100644 backend/app/application_tracker/pagination.py create mode 100644 backend/tests/test_jobscout_pagination.py create mode 100644 deploy/jobscout/gateway.Dockerfile create mode 100644 deploy/jobscout/gateway.Dockerfile.dockerignore create mode 100644 deploy/jobscout/web.Dockerfile create mode 100644 deploy/jobscout/web.Dockerfile.dockerignore create mode 100644 docker/docker-compose.jobscout-images.yaml create mode 100644 jobscout-web/api-client.js create mode 100644 jobscout-web/core.js create mode 100644 jobscout-web/test_modules.js create mode 100644 jobscout-web/tracker-state.js diff --git a/.github/workflows/jobscout-deploy.yml b/.github/workflows/jobscout-deploy.yml index 8ae7047b..9cb337c7 100644 --- a/.github/workflows/jobscout-deploy.yml +++ b/.github/workflows/jobscout-deploy.yml @@ -12,13 +12,15 @@ on: - 'jobscout-web/**' - 'deploy/jobscout/**' - 'docker/**' + - 'skills/public/**' + - '.dockerignore' - '.github/workflows/jobscout-deploy.yml' push: branches: [main] paths: - '.github/workflows/jobscout-deploy.yml' - 'deploy/jobscout/**' - - 'docker/docker-compose.jobscout.yaml' + - 'docker/docker-compose.jobscout*.yaml' - 'docker/nginx/jobscout.conf' - 'backend/Dockerfile' - 'backend/app/**' @@ -28,6 +30,7 @@ on: - 'scripts/jobscout*.py' - 'jobscout-web/**' - '.dockerignore' + - 'skills/public/**' permissions: contents: read concurrency: @@ -36,7 +39,10 @@ concurrency: jobs: container-smoke: runs-on: ubuntu-latest - timeout-minutes: 40 + timeout-minutes: 60 + permissions: + contents: read + packages: write steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 @@ -94,9 +100,73 @@ jobs: assert status['needs_setup'] is False print('Administrator restored into a fresh data volume') PY + - name: Package the tested application as commit-tagged images + env: + REPOSITORY: ${{ github.repository }} + COMMIT: ${{ github.sha }} + run: | + python scripts/jobscout.py down + prefix="ghcr.io/${REPOSITORY,,}" + tag="sha-${COMMIT}" + echo "JOBSCOUT_RELEASE_PREFIX=${prefix}" >> "$GITHUB_ENV" + echo "JOBSCOUT_RELEASE_TAG=${tag}" >> "$GITHUB_ENV" + docker build -f deploy/jobscout/gateway.Dockerfile --label "org.opencontainers.image.source=https://github.com/${REPOSITORY}" --label "org.opencontainers.image.revision=${COMMIT}" -t "${prefix}-gateway:${tag}" . + docker build -f deploy/jobscout/web.Dockerfile --label "org.opencontainers.image.source=https://github.com/${REPOSITORY}" --label "org.opencontainers.image.revision=${COMMIT}" -t "${prefix}-web:${tag}" . + docker tag jobscout-frontend:latest "${prefix}-capabilities:${tag}" + python - <<'PY' + import os + from pathlib import Path + p = Path('.jobscout/.env') + p.write_text(p.read_text() + '\nJOBSCOUT_DEPLOY_MODE=images\nJOBSCOUT_IMAGE_PREFIX=ghcr.io/' + os.environ['REPOSITORY'].lower() + '\nJOBSCOUT_IMAGE_TAG=sha-' + os.environ['COMMIT'] + '\nJOBSCOUT_DATA_VOLUME=jobscout-image-data\n') + PY + - name: Start packaged images and verify no source mounts + run: | + python scripts/jobscout.py up --no-pull + python scripts/jobscout_smoke.py + python - <<'PY' + import importlib.util, json, subprocess, urllib.request + from pathlib import Path + spec = importlib.util.spec_from_file_location('deploy', 'scripts/jobscout.py') + deploy = importlib.util.module_from_spec(spec) + spec.loader.exec_module(deploy) + directory = Path('.jobscout').resolve() + config = json.loads(deploy.compose(directory, ['config', '--format', 'json'], check=True, capture_output=True, text=True).stdout) + services = config['services'] + for service in ('gateway', 'frontend', 'nginx'): + assert 'build' not in services[service] + assert {v['target'] for v in services['gateway']['volumes']} == {'/config/config.yaml', '/config/extensions_config.json', '/data'} + assert {v['target'] for v in services['nginx']['volumes']} == {'/usr/share/nginx/jobscout/runtime-config.js'} + deploy.compose(directory, ['exec', '-T', 'gateway', '/app/backend/.venv/bin/python', '-c', "from pathlib import Path; assert list(Path('/app/skills/public').glob('*/SKILL.md'))"], check=True) + for name in ('core', 'api-client', 'tracker-state'): + response = urllib.request.urlopen('http://127.0.0.1:2026/' + name + '.js') + assert response.status == 200 and 'javascript' in response.headers['Content-Type'] + print('Packaged assets and public skills ready; no application source bind mounts') + PY + - name: Sign in to GitHub Container Registry + if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Publish tested commit images + if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' + run: | + for component in gateway capabilities web; do + docker push "${JOBSCOUT_RELEASE_PREFIX}-${component}:${JOBSCOUT_RELEASE_TAG}" + done + echo "Published image tag: \`${JOBSCOUT_RELEASE_TAG}\` (linux/amd64)" >> "$GITHUB_STEP_SUMMARY" - name: Service diagnostics if: failure() - run: docker compose --project-name jobscout --env-file .jobscout/.env -f docker/docker-compose.jobscout.yaml logs --tail 80 + run: | + python - <<'PY' + import importlib.util + from pathlib import Path + spec = importlib.util.spec_from_file_location('deploy', 'scripts/jobscout.py') + deploy = importlib.util.module_from_spec(spec) + spec.loader.exec_module(deploy) + deploy.compose(Path('.jobscout').resolve(), ['logs', '--tail', '80']) + PY - name: Stop disposable services if: always() run: python scripts/jobscout.py down diff --git a/README.md b/README.md index 5f506d32..74c5c11f 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,8 @@ python scripts/jobscout.py up 打开 **http://localhost:2026**,创建管理员账号即可进入工作台。首次构建会下载依赖并编译上游能力中心;命令会等待健康检查通过后才报告启动成功。 +服务器也可以直接拉取 **按完整提交号标记的预构建镜像**,省去现场安装依赖和编译。在 `.jobscout/.env` 设置 `JOBSCOUT_DEPLOY_MODE=images` 与已发布的 `JOBSCOUT_IMAGE_TAG=sha-完整提交号`,再运行同一个 `up` 命令。镜像模式要求 Compose 2.24.4+,当前发布 `linux/amd64`;具体版本获取、访问权限和升级步骤见[镜像部署](docs/jobscout/deployment.md#使用预构建镜像)。 + 这套入口会为你准备: - **独立配置目录:** `.jobscout/`,初始化不覆盖原来根目录的本地开发配置。 @@ -193,6 +195,8 @@ Linux/macOS 将 Python 路径替换为 `backend/.venv/bin/python`,或在 backe [部署 CI](.github/workflows/jobscout-deploy.yml) 在临时 Linux 实例验证镜像构建、首次初始化、同域认证、持久化与 Chromium 启动;它不代表真实招聘网站和模型服务已经验收。前端测试还覆盖注册策略、连接重试、桌面/移动端、任务并发、取消及历史记录操作。 +投递工作台按 **每页 50 条**加载,支持跨页环节筛选;新增记录不会把后续页面的游标挤乱。顶部总数与状态统计仍覆盖全部投递,CSV 导出与批量检查也保持完整范围。请求处理、分页状态、报告渲染与证据展示已拆为独立模块,页面仍无需构建即可运行。实现与性能边界见[可靠性记录](docs/jobscout/reliability.md)。 + ## 继续了解 | 想了解什么 | 文档 | diff --git a/backend/app/application_tracker/AGENTS.md b/backend/app/application_tracker/AGENTS.md index bced136e..e9922299 100644 --- a/backend/app/application_tracker/AGENTS.md +++ b/backend/app/application_tracker/AGENTS.md @@ -1,5 +1,10 @@ # Application tracker +`pagination.py` returns owner-scoped cursor pages: only page rows load full evidence; +global summaries scan compact state. Reuse MailStore resolution for mail conflicts +and manual stages. Keep the legacy full list for exports/batches; no count claims +based on the current page. Cursor mutation and page/full-list parity tests are offline. + Refresh writes compare revision, identity and checked_at inside BEGIN IMMEDIATE; preserve notification/history atomicity. Manual, batch and scheduled checks share the per-loop refresh lock. Connections close on context exit. Gateway caches store initialization off-loop. CSV export/import share versioned text encoding and bilingual column mapping; never import status history from a CSV. Stage 7: `scheduling.py` owns owner-scoped settings, atomic per-day reservations and transactional notices. `scheduled_graph.py` accepts only the gated, trusted native scheduler context; never assemble the lead agent here. `allow_model_fallback=False` must avoid model initialization on every tracker branch. Cancellation/failure consumes reserved quota. Portal notices use the check transaction; mail notices share source resolution and the mail transaction. See `docs/jobscout/scheduled-refresh.md`. diff --git a/backend/app/application_tracker/email/store.py b/backend/app/application_tracker/email/store.py index d0df3667..f72126d5 100644 --- a/backend/app/application_tracker/email/store.py +++ b/backend/app/application_tracker/email/store.py @@ -1,6 +1,7 @@ """Private mail ledger and a derived view; portal history is never overwritten.""" import json +from collections import defaultdict from datetime import UTC, datetime from hashlib import sha256 @@ -41,13 +42,13 @@ def save(self, user_id: str, message: MailMessage, event: MailEvent) -> bool: if row is None or row["company"] != event.company or row["role"] != event.role or not all(is_evidence_grounded(source, text) for text in (event.company, event.role)): raise ValueError("Mail application ownership or evidence mismatch") app = self.tracker._application_from_row(row) - previous = self._decorate([app], self._events(connection, user_id))[0] + previous = self._decorate([app], self._events(connection, user_id, application_ids=[app.id]))[0] result = connection.execute( "INSERT OR IGNORE INTO jobscout_mail_events(user_id,application_id,fingerprint,event_json,received_at) VALUES(?,?,?,?,?)", (user_id, event.application_id, fingerprint, event.model_dump_json(), event.received_at.astimezone(UTC).isoformat()), ) if result.rowcount and previous is not None: - current = self._decorate([app], self._events(connection, user_id))[0] + current = self._decorate([app], self._events(connection, user_id, application_ids=[app.id]))[0] before = (previous.source_summary or {}).get("status", previous.status.value) after = current.source_summary or {} if before != "未知" and after.get("source") == "email" and not after.get("conflict") and after["status"] != before: @@ -69,26 +70,36 @@ def list_events(self, user_id: str, application_id: int | None = None) -> list[d def decorate(self, user_id: str, applications: list): # One bounded query per user listing, not per application. with self.tracker._connect() as connection: - events = self._events(connection, self.tracker._validated_user_id(user_id)) + events = self._events(connection, self.tracker._validated_user_id(user_id), application_ids=[app.id for app in applications]) return self._decorate(applications, events) @staticmethod - def _events(connection, user_id): + def _events(connection, user_id, *, application_ids=None, compact=False): + if application_ids == []: + return [] + # Bind at most one page's IDs; full export keeps the original owner query. + bounded_ids = application_ids if application_ids is not None and len(application_ids) <= 100 else None + predicate = " AND application_id IN (" + ",".join("?" for _ in bounded_ids) + ")" if bounded_ids else "" + projection = "json_object('application_id',application_id,'status',json_extract(event_json,'$.status'),'review_reason',NULL,'received_at',json_extract(event_json,'$.received_at'))" if compact else "event_json" rows = connection.execute( - """SELECT id,event_json FROM ( - SELECT id,event_json,DENSE_RANK() OVER (PARTITION BY application_id ORDER BY received_at DESC) AS n - FROM jobscout_mail_events WHERE user_id=? AND application_id IS NOT NULL + f"""SELECT id,event_json FROM ( + SELECT id,{projection} AS event_json,DENSE_RANK() OVER (PARTITION BY application_id ORDER BY received_at DESC) AS n + FROM jobscout_mail_events WHERE user_id=? AND application_id IS NOT NULL {predicate} AND json_extract(event_json,'$.review_reason') IS NULL AND json_extract(event_json,'$.status') IS NOT NULL ) WHERE n <= 2 ORDER BY id DESC""", - (user_id,), + (user_id, *(bounded_ids or [])), ).fetchall() return sorted([{"id": row["id"], **json.loads(row["event_json"])} for row in rows], key=lambda event: (datetime.fromisoformat(event["received_at"]), event["id"]), reverse=True) @staticmethod def _decorate(applications, events): + by_application = defaultdict(list) + for event in events: + if event["status"] and not event["review_reason"]: + by_application[event["application_id"]].append(event) results = [] for app in applications: - candidates = [event for event in events if event["application_id"] == app.id and event["status"] and not event["review_reason"]] + candidates = by_application[app.id] if not candidates: results.append(app) continue @@ -108,10 +119,10 @@ def _decorate(applications, events): "source": "email" if selected else "portal", "conflict": conflict, "email_status": status, - "evidence": latest["quote"], + "evidence": latest.get("quote", ""), "received_at": latest["received_at"], - "event_at": latest["event_at"], - "time_evidence": latest["time_quote"], + "event_at": latest.get("event_at"), + "time_evidence": latest.get("time_quote", ""), "event_id": latest["id"], } results.append(app.model_copy(update={"source_summary": summary})) diff --git a/backend/app/application_tracker/pagination.py b/backend/app/application_tracker/pagination.py new file mode 100644 index 00000000..20efa16f --- /dev/null +++ b/backend/app/application_tracker/pagination.py @@ -0,0 +1,77 @@ +"""Bounded full-row pages with compact global status projections. + +Counts scan small status fields, never every application's evidence/notes. Mail +resolution stays in MailStore so pagination cannot disagree with single reads. +""" + +from collections import Counter +from datetime import datetime + +from pydantic import BaseModel + +from .email.store import TERMINAL, MailStore +from .models import ApplicationStatus, CheckResult + + +class SummaryRow(BaseModel): + id: int + status: ApplicationStatus + stage: str + stage_manual: bool + confidence: float + checked_at: datetime | None + check_result: CheckResult | None + source_summary: dict | None = None + + +def presentation(row): + source = row.source_summary or {} + mail = source.get("source") == "email" and not source.get("conflict") + status = source["status"] if mail else row.status.value + stage = source["status"] if mail and not row.stage_manual else row.stage or status + review = bool(source.get("conflict") or status == "未知" or (not mail and ((row.checked_at and row.confidence < 0.7) or (row.check_result and row.check_result is not CheckResult.SUCCESS)))) + if review: + bucket = "review" + elif status == "Offer": + bucket = "offers" + elif status not in TERMINAL: + bucket = "active" + else: + bucket = None + return stage, bucket + + +def list_page(store, user_id, *, limit=50, before_id=None, stage=None): + user_id = store._validated_user_id(user_id) + if not 1 <= limit <= 100 or (before_id is not None and before_id < 1): + raise ValueError("Invalid page bounds") + with store._connect() as connection: + connection.execute("BEGIN") + rows = connection.execute("SELECT id,status,stage,stage_manual,confidence,checked_at,check_result FROM applications WHERE user_id=? ORDER BY id DESC", (user_id,)).fetchall() + projected = MailStore._decorate([SummaryRow.model_validate(dict(row)) for row in rows], MailStore._events(connection, user_id, compact=True)) + summary = {"total": len(projected), "active": 0, "review": 0, "offers": 0} + stages, matching = Counter(), [] + for row in projected: + label, bucket = presentation(row) + stages[label] += 1 + if bucket: + summary[bucket] += 1 + if stage is None or label == stage: + matching.append(row.id) + eligible = [row_id for row_id in matching if before_id is None or row_id < before_id] + ids = eligible[:limit] + items = [] + if ids: + placeholders = ",".join("?" for _ in ids) + selected = connection.execute(f"SELECT * FROM applications WHERE user_id=? AND id IN ({placeholders}) ORDER BY id DESC LIMIT ?", (user_id, *ids, limit)).fetchall() + items = MailStore._decorate([store._application_from_row(row) for row in selected], MailStore._events(connection, user_id, application_ids=ids)) + has_more = len(eligible) > limit + return { + "items": items, + "total": summary["total"], + "filtered_total": len(matching), + "summary": summary, + "stages": [{"label": label, "count": count} for label, count in stages.items()], + "has_more": has_more, + "next_before_id": ids[-1] if has_more else None, + } diff --git a/backend/app/application_tracker/store.py b/backend/app/application_tracker/store.py index fc02950c..fdc51f9f 100644 --- a/backend/app/application_tracker/store.py +++ b/backend/app/application_tracker/store.py @@ -627,6 +627,11 @@ def import_applications( updated += 1 return ImportSummary(inserted=inserted, updated=updated, total=len(applications)) + def list_applications_page(self, user_id: str, *, limit=50, before_id=None, stage=None) -> dict: + from .pagination import list_page + + return list_page(self, user_id, limit=limit, before_id=before_id, stage=stage) + def list_applications(self, user_id: str) -> list[StoredApplication]: user_id = self._validated_user_id(user_id) with self._connect() as connection: diff --git a/backend/app/gateway/routers/jobscout.py b/backend/app/gateway/routers/jobscout.py index b1480e32..26b7e978 100644 --- a/backend/app/gateway/routers/jobscout.py +++ b/backend/app/gateway/routers/jobscout.py @@ -9,7 +9,7 @@ from threading import Lock from anyio import CancelScope -from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile +from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, UploadFile from fastapi.encoders import jsonable_encoder from fastapi.responses import Response, StreamingResponse from pydantic import BaseModel, Field, field_validator, model_validator @@ -515,6 +515,13 @@ async def list_tracker_applications(request: Request) -> list[StoredApplication] return await asyncio.to_thread((await _get_tracker_store()).list_applications, user_id) +@router.get("/tracker/applications/page") +@require_permission("runs", "read") +async def list_tracker_applications_page(request: Request, limit: int = Query(50, ge=1, le=100), before_id: int | None = Query(None, ge=1), stage: str | None = Query(None, max_length=60)) -> dict: + del request + return await asyncio.to_thread((await _get_tracker_store()).list_applications_page, get_effective_user_id(), limit=limit, before_id=before_id, stage=stage) + + @router.post("/tracker/applications", response_model=StoredApplication) @require_permission("runs", "create") async def create_tracker_application(body: TrackerApplicationCreate, request: Request) -> StoredApplication: diff --git a/backend/tests/test_jobscout_deployment.py b/backend/tests/test_jobscout_deployment.py index cb561311..c35d0fef 100644 --- a/backend/tests/test_jobscout_deployment.py +++ b/backend/tests/test_jobscout_deployment.py @@ -77,6 +77,50 @@ def test_compose_uses_explicit_env_and_project_directory(tmp_path): assert command[-2:] == ["config", "--quiet"] +@pytest.mark.parametrize("offline", [False, True]) +def test_image_mode_uses_override_and_never_builds_on_up(tmp_path, monkeypatch, offline): + cli = load_cli() + cli.initialize(tmp_path) + env = tmp_path / ".env" + env.write_text(env.read_text(encoding="utf-8") + "\nJOBSCOUT_DEPLOY_MODE=images\nJOBSCOUT_IMAGE_TAG=sha-" + "a" * 40 + "\n", encoding="utf-8") + command = cli.compose_command(tmp_path, ["up"]) + assert any(item.endswith("docker-compose.jobscout-images.yaml") for item in command) + monkeypatch.setattr(cli.sys, "argv", ["jobscout.py", "up", "--directory", str(tmp_path), *(["--no-pull"] if offline else [])]) + monkeypatch.setattr(cli, "doctor", lambda _: True) + calls = [] + monkeypatch.setattr(cli, "compose", lambda directory, args, **kwargs: calls.append(args) or SimpleNamespace(returncode=0)) + assert cli.main() == 0 + assert calls[0][0] == ("up" if offline else "pull") + assert "--no-build" in calls[-1] and "--build" not in calls[-1] + assert calls[-1][calls[-1].index("--pull") + 1] == "never" + + +def test_image_mode_requires_a_commit_tag(tmp_path): + cli = load_cli() + cli.initialize(tmp_path) + env = tmp_path / ".env" + env.write_text(env.read_text(encoding="utf-8") + "\nJOBSCOUT_DEPLOY_MODE=images\nJOBSCOUT_IMAGE_TAG=latest\n", encoding="utf-8") + assert any("JOBSCOUT_IMAGE_TAG" in error for error in cli.configuration_errors(tmp_path)) + + +def test_image_pull_failure_does_not_start_partial_release(tmp_path, monkeypatch): + cli = load_cli() + cli.initialize(tmp_path) + env = tmp_path / ".env" + env.write_text(env.read_text(encoding="utf-8") + "\nJOBSCOUT_DEPLOY_MODE=images\n", encoding="utf-8") + monkeypatch.setattr(cli.sys, "argv", ["jobscout.py", "up", "--directory", str(tmp_path)]) + monkeypatch.setattr(cli, "doctor", lambda _: True) + calls = [] + + def run(directory, arguments, **kwargs): + calls.append(arguments) + raise subprocess.CalledProcessError(1, arguments) + + monkeypatch.setattr(cli, "compose", run) + assert cli.main() == 1 + assert calls == [["pull"]] + + def test_deployment_config_passes_real_schema(tmp_path, monkeypatch): cli = load_cli() cli.initialize(tmp_path) diff --git a/backend/tests/test_jobscout_pagination.py b/backend/tests/test_jobscout_pagination.py new file mode 100644 index 00000000..21f324ec --- /dev/null +++ b/backend/tests/test_jobscout_pagination.py @@ -0,0 +1,93 @@ +"""Synthetic pages retain global counts and owner isolation across mutations.""" + +import json + +import pytest +from fastapi.testclient import TestClient + +from app.application_tracker.models import ApplicationInput +from app.application_tracker.store import ApplicationTrackerStore + + +@pytest.fixture +def tracker(tmp_path): + store = ApplicationTrackerStore(tmp_path / "tracker.db") + store.import_applications("owner", [ApplicationInput(company="Example", role=f"Role {i}", url=f"https://jobs.example.com/{i}") for i in range(123)]) + store.add_application("foreign", ApplicationInput(company="Private", role="Private", url="https://jobs.example.com/private")) + return store + + +def test_pages_are_bounded_and_cursor_survives_inserts_and_deletes(tracker, monkeypatch): + monkeypatch.setattr(tracker, "list_applications", lambda *args: pytest.fail("Do not load complete rows before slicing")) + first = tracker.list_applications_page("owner", limit=50) + assert len(first["items"]) == 50 and first["total"] == 123 and first["has_more"] + assert first["summary"] == {"total": 123, "active": 0, "review": 123, "offers": 0} + assert first["items"][0].id == 123 + tracker.delete_application("owner", 123) + tracker.add_application("owner", ApplicationInput(company="New", role="New", url="https://jobs.example.com/new")) + second = tracker.list_applications_page("owner", limit=50, before_id=first["next_before_id"]) + third = tracker.list_applications_page("owner", limit=50, before_id=second["next_before_id"]) + ids = [row.id for page in (first, second, third) for row in page["items"]] + assert ids == list(range(123, 0, -1)) and not third["has_more"] + assert all(row.company != "Private" for page in (first, second, third) for row in page["items"]) + + +def test_filter_and_summary_include_mail_and_manual_stages(tracker): + with tracker._connect() as connection: + connection.execute("UPDATE applications SET stage='准备材料',stage_manual=1 WHERE id=1") + event = {"application_id": 2, "status": "Offer", "review_reason": None, "received_at": "2026-10-05T00:00:00+00:00", "quote": "Synthetic Offer", "event_at": None, "time_quote": ""} + connection.execute("INSERT INTO jobscout_mail_events(user_id,application_id,fingerprint,event_json,received_at) VALUES(?,?,?,?,?)", ("owner", 2, "fixture", json.dumps(event), event["received_at"])) + result = tracker.list_applications_page("owner", stage="Offer", limit=10) + assert [row.id for row in result["items"]] == [2] + assert result["items"][0].source_summary["source"] == "email" + assert result["summary"] == {"total": 123, "active": 0, "review": 122, "offers": 1} + assert result["filtered_total"] == 1 + assert {item["label"]: item["count"] for item in result["stages"]} == {"未知": 121, "准备材料": 1, "Offer": 1} + assert [row.id for row in tracker.list_applications_page("owner", stage="准备材料")["items"]] == [1] + assert tracker.list_applications_page("owner", stage="Missing")["items"] == [] + + +def test_page_only_materializes_selected_full_rows(tracker, monkeypatch): + original = tracker._application_from_row + materialized = [] + + def record(row): + materialized.append(row["id"]) + return original(row) + + monkeypatch.setattr(tracker, "_application_from_row", record) + page = tracker.list_applications_page("owner", limit=3) + assert materialized == [123, 122, 121] + assert page["total"] == 123 + + +def test_conflicting_mail_has_identical_page_and_full_list_view(tracker): + with tracker._connect() as connection: + for status in ("Offer", "未通过"): + event = {"application_id": 123, "status": status, "review_reason": None, "received_at": "2026-10-05T00:00:00+00:00", "quote": "Synthetic " + status} + connection.execute("INSERT INTO jobscout_mail_events(user_id,application_id,fingerprint,event_json,received_at) VALUES(?,?,?,?,?)", ("owner", 123, status, json.dumps(event), event["received_at"])) + page = tracker.list_applications_page("owner", limit=1, stage="未知") + full = next(row for row in tracker.list_applications("owner") if row.id == 123) + assert page["items"][0] == full + assert full.source_summary["conflict"] + assert page["summary"]["review"] == 123 + assert page["summary"]["offers"] == 0 + + +def test_page_api_bounds_and_auth(tracker, monkeypatch): + from _router_auth_helpers import make_authed_test_app + + from app.gateway.routers import jobscout + + monkeypatch.setattr(jobscout, "_tracker_store", lambda: tracker) + monkeypatch.setattr(jobscout, "get_effective_user_id", lambda: "owner") + app = make_authed_test_app() + app.include_router(jobscout.router) + client = TestClient(app) + path = "/api/jobscout/tracker/applications/page" + assert client.get(path + "?limit=101").status_code == 422 + assert client.get(path + "?limit=0").status_code == 422 + assert client.get(path + "?before_id=-1").status_code == 422 + response = client.get(path + "?limit=3") + assert response.status_code == 200 + assert len(response.json()["items"]) == 3 and response.json()["total"] == 123 diff --git a/deploy/jobscout/env.example b/deploy/jobscout/env.example index 8dff8ba7..65e388da 100644 --- a/deploy/jobscout/env.example +++ b/deploy/jobscout/env.example @@ -16,3 +16,9 @@ AUTH_JWT_SECRET=__GENERATE__ BETTER_AUTH_SECRET=__GENERATE__ DEER_FLOW_INTERNAL_AUTH_TOKEN=__GENERATE__ JOBSCOUT_DATA_VOLUME=jobscout_jobscout-data + +# source builds this checkout; images pulls the three prebuilt application images. +JOBSCOUT_DEPLOY_MODE=source +JOBSCOUT_IMAGE_PREFIX=ghcr.io/funkierry/jobscout +# Image mode requires sha- followed by the complete 40-character published commit. +JOBSCOUT_IMAGE_TAG= diff --git a/deploy/jobscout/gateway.Dockerfile b/deploy/jobscout/gateway.Dockerfile new file mode 100644 index 00000000..b1e0e969 --- /dev/null +++ b/deploy/jobscout/gateway.Dockerfile @@ -0,0 +1,5 @@ +# Wrap the browser-enabled backend image built and tested by source Compose. +ARG BACKEND_IMAGE=jobscout-gateway:latest +FROM ${BACKEND_IMAGE} +COPY skills/public /app/skills/public +COPY LICENSE /app/LICENSE diff --git a/deploy/jobscout/gateway.Dockerfile.dockerignore b/deploy/jobscout/gateway.Dockerfile.dockerignore new file mode 100644 index 00000000..87e5929e --- /dev/null +++ b/deploy/jobscout/gateway.Dockerfile.dockerignore @@ -0,0 +1,5 @@ +** +!skills/ +!skills/public/ +!skills/public/** +!LICENSE diff --git a/deploy/jobscout/web.Dockerfile b/deploy/jobscout/web.Dockerfile new file mode 100644 index 00000000..69c87ace --- /dev/null +++ b/deploy/jobscout/web.Dockerfile @@ -0,0 +1,4 @@ +FROM nginx:stable-alpine +COPY docker/nginx/jobscout.conf /etc/nginx/nginx.conf +COPY jobscout-web/index.html jobscout-web/style.css jobscout-web/runtime-config.js jobscout-web/app.js jobscout-web/core.js jobscout-web/api-client.js jobscout-web/tracker-state.js /usr/share/nginx/jobscout/ +COPY LICENSE /usr/share/nginx/jobscout/LICENSE diff --git a/deploy/jobscout/web.Dockerfile.dockerignore b/deploy/jobscout/web.Dockerfile.dockerignore new file mode 100644 index 00000000..5bab096e --- /dev/null +++ b/deploy/jobscout/web.Dockerfile.dockerignore @@ -0,0 +1,13 @@ +** +!docker/ +!docker/nginx/ +!docker/nginx/jobscout.conf +!jobscout-web/ +!jobscout-web/index.html +!jobscout-web/style.css +!jobscout-web/runtime-config.js +!jobscout-web/app.js +!jobscout-web/core.js +!jobscout-web/api-client.js +!jobscout-web/tracker-state.js +!LICENSE diff --git a/docker/docker-compose.jobscout-images.yaml b/docker/docker-compose.jobscout-images.yaml new file mode 100644 index 00000000..8a650c6a --- /dev/null +++ b/docker/docker-compose.jobscout-images.yaml @@ -0,0 +1,18 @@ +# Requires Docker Compose >= 2.24.4; merged over docker-compose.jobscout.yaml. +# The application and public skills live inside the images. Only operator +# configuration and persistent data are mounted from outside them. +services: + gateway: + build: !reset null + image: ${JOBSCOUT_IMAGE_PREFIX:?Set JOBSCOUT_IMAGE_PREFIX}-gateway:${JOBSCOUT_IMAGE_TAG:?Set JOBSCOUT_IMAGE_TAG} + volumes: !override + - ${JOBSCOUT_DEPLOY_DIR:-../.jobscout}/config.yaml:/config/config.yaml:ro + - ${JOBSCOUT_DEPLOY_DIR:-../.jobscout}/extensions_config.json:/config/extensions_config.json + - jobscout-data:/data + frontend: + build: !reset null + image: ${JOBSCOUT_IMAGE_PREFIX:?Set JOBSCOUT_IMAGE_PREFIX}-capabilities:${JOBSCOUT_IMAGE_TAG:?Set JOBSCOUT_IMAGE_TAG} + nginx: + image: ${JOBSCOUT_IMAGE_PREFIX:?Set JOBSCOUT_IMAGE_PREFIX}-web:${JOBSCOUT_IMAGE_TAG:?Set JOBSCOUT_IMAGE_TAG} + volumes: !override + - ${JOBSCOUT_DEPLOY_DIR:-../.jobscout}/runtime-config.js:/usr/share/nginx/jobscout/runtime-config.js:ro diff --git a/docker/nginx/jobscout.conf b/docker/nginx/jobscout.conf index 1549e849..2449fa1e 100644 --- a/docker/nginx/jobscout.conf +++ b/docker/nginx/jobscout.conf @@ -31,6 +31,7 @@ http { location = / { try_files /index.html =404; add_header Cache-Control "no-cache"; } location = /index.html { add_header Cache-Control "no-cache"; } location = /app.js { add_header Cache-Control "no-cache"; } + location ~ ^/(core|api-client|tracker-state)\.js$ { try_files $uri =404; add_header Cache-Control "no-cache"; } location = /style.css { add_header Cache-Control "no-cache"; } location = /runtime-config.js { add_header Cache-Control "no-store"; } location /api/langgraph/ { diff --git a/docs/jobscout/deployment.md b/docs/jobscout/deployment.md index b6cc9ebf..d16d103b 100644 --- a/docs/jobscout/deployment.md +++ b/docs/jobscout/deployment.md @@ -31,6 +31,33 @@ python scripts/jobscout.py up 打开 **http://localhost:2026**。新实例会直接显示“创建你的管理员账号”;创建完成后进入 JobScout。公开注册默认关闭,已有实例显示登录入口。 +### 使用预构建镜像 + +资源有限的服务器可跳过本机构建。先按上面的步骤克隆仓库、执行 `init`、填写模型参数,再编辑 `.jobscout/.env`: + +```dotenv +JOBSCOUT_DEPLOY_MODE=images +JOBSCOUT_IMAGE_PREFIX=ghcr.io/funkierry/jobscout +JOBSCOUT_IMAGE_TAG=sha-这里替换为已发布版本的完整40位提交号 +``` + +在 GitHub 的 [JobScout deployment 工作流](https://github.com/Funkierry/JobScout/actions/workflows/jobscout-deploy.yml) 找到 **main 分支成功完成发布**的一次运行,从运行摘要复制 `sha-…` 标签。PR 检查仅构建和验证,不发布;main 推送或 main 上手动重跑会在容器验证通过后发布以下三个镜像,标签必须一致: + +- `ghcr.io/funkierry/jobscout-gateway`:后端、Chromium、私有浏览器桌面和内置公开技能。 +- `ghcr.io/funkierry/jobscout-capabilities`:飞书连接与上游能力中心。 +- `ghcr.io/funkierry/jobscout-web`:独立工作台页面及 Nginx 配置。 + +当前 CI 构建 `linux/amd64`。ARM 主机可采用源码构建;此处尚未提供原生 ARM 镜像。镜像模式使用 Compose 的卷替换语法,需要 **Compose 2.24.4 或更新版本**,`doctor` 会检查。[Compose 合并规则](https://docs.docker.com/reference/compose-file/merge/)。 + +```bash +python scripts/jobscout.py doctor +python scripts/jobscout.py up +``` + +`up` 会先拉取所有镜像,再以 `--no-build` 启动并等待健康检查。应用源码、公开技能和网页都已打包;服务器只挂载私有配置、公开运行时地址和数据卷。升级时先备份,再将代码与 `JOBSCOUT_IMAGE_TAG` 切到同一个已发布提交后运行 `up`。恢复旧版本也应配合兼容的数据备份,单纯切镜像不能撤销数据库迁移。 + +GHCR 首次发布的包可能是私有包;仓库公开不代表镜像自动公开。遇到 `denied` 时,仓库所有者可在包设置中开放可见性,或部署者使用具备该包读取权限的账号通过 `docker login ghcr.io` 登录。访问令牌放 Docker 凭据存储,不放网页配置。[GHCR 访问说明](https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry)。已离线导入镜像时可用 `up --no-pull`;平常使用默认拉取即可。 + ## 2. 为什么保留四个服务 | 服务 | 职责 | 宿主机访问 | @@ -118,7 +145,7 @@ python scripts/jobscout.py up 命令要求 Gateway 已停止,并拒绝向非空卷写入。恢复前使用原版本镜像/代码,确认账号、投递和文件后再升级。归档路径会先校验,再在临时目录解包;原数据卷一直保留,切回原卷名即可重新启动旧数据。第一次运行恢复命令需要本地已有构建镜像。Redis 是事件传输缓存,不在数据备份中;持久会话和运行记录由 `/data` 数据库恢复。 -更新代码后再次运行 `python scripts/jobscout.py up` 即可重建并检查健康状态。更新前先备份;有数据库迁移的版本应结合对应变更说明安排回退。 +源码模式更新代码后再次运行 `python scripts/jobscout.py up` 即可重建并检查健康状态;镜像模式按上面的步骤更新发布标签。更新前先备份;有数据库迁移的版本应结合对应变更说明安排回退。 ## 6. 可选功能与费用 @@ -143,6 +170,8 @@ python scripts/jobscout.py up [JobScout deployment CI](../../.github/workflows/jobscout-deploy.yml) 在临时 Linux 环境构建四个服务,检查入口、管理员初始化、Cookie/CSRF、关闭注册、目标岗位保存、能力中心、Chromium 启动,以及容器停止再启动后的账号持久化。测试使用合成账号,不调用模型或真实招聘网站。 +随后 CI 会备份并恢复到新卷,再将应用打包为三个镜像,在另一个全新数据卷启动镜像模式,复测初始化与接口,同时检查页面模块、内置技能以及不存在应用源码挂载。只有这些检查通过,main 上的运行才会推送提交标签;发布失败的运行不能作为可部署版本。 + `scripts/jobscout_smoke.py` 会创建合成管理员,仅供全新、可丢弃的本地测试实例;不要在自己的正式实例运行。功能回归和离线评测见 [README](../../README.md#开发与验证)。 容器健康不等于模型研究质量或真实招聘网站兼容性。个人部署完成后,分别验收一次自己的岗位检查、面试准备和飞书读取,才能确认所选服务商与站点组合可用。 diff --git a/docs/jobscout/reliability.md b/docs/jobscout/reliability.md index 1622f86e..e096c2da 100644 --- a/docs/jobscout/reliability.md +++ b/docs/jobscout/reliability.md @@ -11,11 +11,16 @@ | 连接和列表查询开销 | 事务结束关闭连接,缓存按路径初始化的 store;目标列表合并查询 | 12 个岗位从 37 次查询降为 3 次,账号隔离保持 | | 备份缺少恢复入口 | 独立归档模块,恢复只写空卷,拒绝越界路径和特殊文件 | 本地归档往返、拒绝覆盖;Linux 容器恢复演练 | | 本地通过但 CI 漏项 | 修复配置隔离/路由覆盖/UTF-8 读取;新增 standalone 浏览器门禁 | 上游 unit/lint/blocking-io + JobScout 专项 + 部署 CI | +| 投递列表一次加载全部内容 | 默认 50、上限 100 条的 ID 游标分页;全局统计与环节筛选保留邮件/手动状态语义 | 123 条合成记录跨页、插入/删除、账号隔离、冲突邮件与失败重试 | +| 前端请求和展示逻辑混在一起 | 提取 `api-client.js`、`core.js`、`tracker-state.js`;无构建流程 | CSRF、取消/超时、报告来源校验、过期响应、桌面/移动端翻页 | +| 服务器必须安装依赖并编译 | CI 打包三个应用镜像,按完整提交号发布;CLI 自动选用镜像覆盖配置 | 拉取失败停止启动、容器健康检查、无源码挂载的首次启动 | CSV 对公式形态字段加制表符文本前缀并整体加引号,版本标记帮助导入恢复原值;不同表格软件可能重新解释保存后的内容,修改过的第三方文件应以实际导入结果为准。导入处理的是岗位登记字段,历史检查和通知请通过数据卷备份恢复。 预算基于供应商响应中的用量,无法撤销已经发出的请求。单条投递检查有独立预算,批量刷新不是一个全局费用预算。单进程锁用于减少重复工作,SQLite 事务校验负责防止过期写入;当前部署仍要求一个 Gateway worker。 -代码拆分集中在 CSV、预算和归档这三个有清楚边界的模块。后续仍可做:长列表分页、独立拆分前端视图与状态管理、发布带固定版本的预构建镜像。真实站点覆盖率、完整流程耗时与模型成本评测仍按[评测记录](metrics.md)标为待运行,不能用本轮合成测试代替。 +列表通过 `/api/jobscout/tracker/applications/page` 获取一页完整记录,以 `before_id` 继续向较小 ID 翻页。统计和筛选仍扫描当前用户的紧凑状态字段及邮件状态投影,因此该部分开销随记录数量增长;完整证据、备注、响应数据和 DOM 行数则受到页大小限制。本轮没有把该查询描述成常数复杂度,也没有引入未经测量的响应耗时数字。全量列表保留给兼容调用,CSV 导出与批量刷新保持全部记录范围。 + +前端提取了请求、纯展示函数和分页状态模块,视图与业务交互仍由 `app.js` 组织。后续可根据实际使用量继续拆分视图、优化全局统计查询及增加 ARM 镜像。真实站点覆盖率、完整流程耗时与模型成本评测仍按[评测记录](metrics.md)标为待运行,不能用本轮合成测试代替。 验证入口:`backend/scripts/jobscout_offline_tests.py`、`jobscout-web/test_*.py`、`jobscout-web/test_pure.js`;GitHub Actions 会保存每次运行的测试结果和容器日志。 diff --git a/jobscout-web/AGENTS.md b/jobscout-web/AGENTS.md index 2782fee5..7cc472ae 100644 --- a/jobscout-web/AGENTS.md +++ b/jobscout-web/AGENTS.md @@ -18,6 +18,11 @@ All browser tests intercept requests and use synthetic data. Run `test_pure.js`, with the backend Playwright environment. Keep runtime-config.js in their asset fixtures. Live research and deployment smoke are separate, explicit operations. +Load core.js, api-client.js and tracker-state.js before app.js; serve them in browser +fixtures and Nginx. Tracker uses cursor pages (50 rows), server global counts and +stage filters; CSV/batch remain complete. Failed navigation restores its committed +cursor/filter. Session/request ownership guards must still reject late responses. + CSV import uses the visible button/file input and existing CSRF-aware API helper; keep its browser test and busy-state behavior. JobScout regression CI runs all five standalone test entrypoints, with every browser request intercepted. diff --git a/jobscout-web/README.md b/jobscout-web/README.md index e09da652..fdd80a26 100644 --- a/jobscout-web/README.md +++ b/jobscout-web/README.md @@ -37,6 +37,10 @@ style uses solid blue accents, neutral surfaces, clear type and borders. Summary cards count all saved applications, confirmed active processes, review-needed records and confirmed offers; they are calculated from current records, never placeholder statistics. Unknown, conflicting and failed checks remain in review. +The table loads 50 applications per page, newest IDs first. Previous/next navigation +and stage filters use the server's cursor endpoint; summary cards and filter counts +cover every saved application. Failed page requests retain the last successful +page. CSV export and batch refresh continue to cover all records. The left sidebar contains task-mode navigation, new-chat and searchable DeerFlow thread history, plus the current account. The main area has an empty-state prompt gallery, evidence/status header, inline reports, resume @@ -82,6 +86,11 @@ than claiming the server stopped. Restart Gateway after updating the backend. ## Run locally +`core.js` owns pure display/report helpers, `api-client.js` owns credentials, +CSRF and request cancellation, and `tracker-state.js` owns pagination navigation. +Load them before `app.js`, which connects those modules to the DOM and task state. +These scripts also expose CommonJS exports for offline Node checks; no bundler is needed. + For a fresh machine or server, use the [Docker deployment guide](../docs/jobscout/deployment.md). It serves this client at `/`, proxies the Gateway on the same origin, and retains the upstream Capability Center for Feishu setup. Fresh instances create their diff --git a/jobscout-web/api-client.js b/jobscout-web/api-client.js new file mode 100644 index 00000000..35bd3eba --- /dev/null +++ b/jobscout-web/api-client.js @@ -0,0 +1,56 @@ +// Same-origin credentials, CSRF and cancellation share one transport boundary. +(function (root, factory) { + if (typeof module !== "undefined") module.exports = factory(); + else root.JobScoutApi = factory(); +})(globalThis, function () { + function createApiClient({ base = "", fetcher, cookie = () => "" }) { + return async function request( + path, + { + method = "GET", + json, + form, + headers = {}, + signal, + timeoutMs = 30000, + } = {}, + ) { + const opts = { method, credentials: "include", headers: { ...headers } }; + const signals = [ + signal, + timeoutMs > 0 ? AbortSignal.timeout(timeoutMs) : null, + ].filter(Boolean); + if (signals.length) + opts.signal = + signals.length === 1 ? signals[0] : AbortSignal.any(signals); + if (json !== undefined) { + opts.headers["Content-Type"] = "application/json"; + opts.body = JSON.stringify(json); + } else if (form !== undefined) opts.body = form; + if (method !== "GET" && method !== "HEAD") { + const match = cookie().match(/(?:^|; )csrf_token=([^;]*)/); + if (match) opts.headers["X-CSRF-Token"] = decodeURIComponent(match[1]); + } + return fetcher(base + path, opts); + }; + } + async function decodeResponse(res) { + let body = null; + try { + body = await res.json(); + } catch (_) { + /* no body */ + } + if (!res.ok) { + const msg = body?.detail?.message || body?.detail || res.statusText; + const error = new Error( + typeof msg === "string" ? msg : JSON.stringify(msg), + ); + error.status = res.status; + error.code = body?.detail?.code; + throw error; + } + return body; + } + return { createApiClient, decodeResponse }; +}); diff --git a/jobscout-web/app.js b/jobscout-web/app.js index f3a02243..ead26deb 100644 --- a/jobscout-web/app.js +++ b/jobscout-web/app.js @@ -2,26 +2,14 @@ // No build step: plain fetch + manual SSE parsing against the documented // Gateway shapes (login/CSRF, threads, uploads, runs/stream). -function resolveDeploymentConfig(config = {}, location = {}) { - const localSplit = location.protocol === "http:" && location.port === "5500" - && ["localhost", "127.0.0.1", "[::1]"].includes(location.hostname); - const localHost = location.hostname || "localhost"; - const gatewayBase = config.gatewayBase ?? (localSplit ? `http://${localHost}:8001` : ""); - const capabilityCenterUrl = config.capabilityCenterUrl - ?? (localSplit ? `http://${localHost}:3000/workspace/capabilities` : "/workspace/capabilities"); - function validateUrl(value, allowPath) { - if (typeof value !== "string") throw new Error("部署地址必须是字符串"); - if (allowPath && /^\/(?!\/)/.test(value) && !value.includes("\\")) return value; - if (!value && !allowPath) return ""; - const parsed = new URL(value); - if (!["http:", "https:"].includes(parsed.protocol) || parsed.username || parsed.password - || (!allowPath && (parsed.pathname !== "/" || parsed.search || parsed.hash))) { - throw new Error("部署地址必须是有效的 HTTP(S) 地址,且不能包含账号密码"); - } - return allowPath ? parsed.href : parsed.origin; - } - return { gatewayBase: validateUrl(gatewayBase, false), capabilityCenterUrl: validateUrl(capabilityCenterUrl, true) }; -} +const { + resolveDeploymentConfig, isTerminalTrackerStatus, trackerDisplayRow, + trackerRowPresentation, trackerSummary, trackerStageWaitText, trackerStageLabel, + trackerStageFilterOptions, filterTrackerRows, parseSseFrames, jobScoutRunContext, + guardedMessageText, contentToText, extractLastVisibleAiText, looksLikeReport, + sanitizeJobScoutReportMarkdown, normalizeReportMarkdownForRender, + escapeHtml, inlineMd, markdownTableCells, markdownToHtml, +} = typeof require === "function" ? require("./core.js") : globalThis.JobScoutCore; const DEPLOYMENT = resolveDeploymentConfig( typeof window !== "undefined" ? window.JOBSCOUT_CONFIG || {} : {}, @@ -29,144 +17,13 @@ const DEPLOYMENT = resolveDeploymentConfig( ); const GATEWAY_BASE = DEPLOYMENT.gatewayBase; -const TERMINAL_TRACKER_STATUSES = new Set(["Offer", "未通过", "流程终止"]); -const STOPPED_TRACKER_STATUSES = new Set(["未通过", "流程终止"]); - -function isTerminalTrackerStatus(status) { - return TERMINAL_TRACKER_STATUSES.has(status); -} - -function trackerDisplayRow(row) { - const source = row?.source_summary; - if (!source || source.conflict || source.source !== "email") return row; - return { ...row, portal_status: row.status, portal_evidence: row.evidence, status: source.status, - stage: row.stage_manual ? row.stage : source.status, raw_status: source.status, - evidence: source.evidence, checked_at: source.received_at, changed_at: source.received_at, - check_result: "成功", confidence: 1, changed: false }; -} - -function trackerRowPresentation(row) { - const terminal = Boolean(row?.terminal) || isTerminalTrackerStatus(row?.status); - const needsReview = Boolean(row?.checked_at) && Number(row?.confidence) < 0.7; - const changed = Boolean(row?.changed && row?.previous_status); - const stopped = STOPPED_TRACKER_STATUSES.has(row?.status) || STOPPED_TRACKER_STATUSES.has(row?.stage); - const unknown = needsReview || !row?.status || row.status === "未知" || (row.check_result && row.check_result !== "成功"); - return { - terminal, - canRefresh: !terminal, - needsReview, - changeText: changed ? `${row.previous_status} → ${row.status}` : "", - tone: changed ? "changed" : (needsReview ? "review" : "normal"), - statusTone: stopped ? "stopped" : (unknown ? "unknown" : "active"), - }; -} - -function trackerSummary(rows) { - const summary = { total: rows.length, active: 0, review: 0, offers: 0 }; - for (const stored of rows) { - const row = trackerDisplayRow(stored); - const presentation = trackerRowPresentation(row); - const review = row.source_summary?.conflict || presentation.needsReview || !row.status || row.status === "未知" || (row.check_result && row.check_result !== "成功"); - if (review) summary.review += 1; - else if (row.status === "Offer") summary.offers += 1; - else if (!presentation.terminal) summary.active += 1; - } - return summary; -} - -function trackerStageWaitText(row, now = new Date()) { - if (!row?.status || row.status === "未知") return "阶段待确认"; - if (row.terminal || isTerminalTrackerStatus(row.status)) return "流程已结束"; - if (row.stage_manual) return "手动环节,起点待确认"; - if (!row.changed_at) return "等待首次检查"; - const started = new Date(row.changed_at); - const elapsed = now.getTime() - started.getTime(); - if (!Number.isFinite(elapsed) || elapsed < 0) return "起点时间待确认"; - const days = Math.floor(elapsed / 86400000); - return days < 1 ? "不足 1 天(自首次识别)" : `至少 ${days} 天(自首次识别)`; -} - -function trackerStageLabel(row) { - row = trackerDisplayRow(row); - return row?.stage || row?.status || "未分类"; -} - -function trackerStageFilterOptions(rows, stages = []) { - const counts = new Map(); - for (const row of rows) { - const label = trackerStageLabel(row); - counts.set(label, (counts.get(label) || 0) + 1); - } - const ordered = [...new Set(stages)].filter((label) => counts.has(label)); - for (const label of counts.keys()) { - if (!ordered.includes(label)) ordered.push(label); - } - return ordered.map((label) => ({ label, count: counts.get(label) })); -} - -function filterTrackerRows(rows, stage) { - return stage === null ? rows : rows.filter((row) => trackerStageLabel(row) === stage); -} - -function parseSseFrames(buffer) { - const normalized = String(buffer || "").replace(/\r\n/g, "\n"); - const frames = normalized.split("\n\n"); - const remainder = frames.pop() || ""; - const events = []; - for (const frame of frames) { - const data = frame - .split("\n") - .filter((line) => line.startsWith("data:")) - .map((line) => line.slice(5).trimStart()) - .join("\n"); - if (data) events.push(JSON.parse(data)); - } - return { events, remainder }; -} - -// ---------------------------------------------------------------- helpers -- - function $(id) { return document.getElementById(id); } -function getCookie(name) { - const match = document.cookie.match(new RegExp("(?:^|; )" + name + "=([^;]*)")); - return match ? decodeURIComponent(match[1]) : null; -} - -async function api(path, { method = "GET", json, form, headers = {}, signal, timeoutMs = 30000 } = {}) { - const opts = { method, credentials: "include", headers: { ...headers } }; - const signals = [signal, timeoutMs > 0 ? AbortSignal.timeout(timeoutMs) : null].filter(Boolean); - if (signals.length) opts.signal = signals.length === 1 ? signals[0] : AbortSignal.any(signals); - - if (json !== undefined) { - opts.headers["Content-Type"] = "application/json"; - opts.body = JSON.stringify(json); - } else if (form !== undefined) { - opts.body = form; // FormData or URLSearchParams — browser sets Content-Type - } - - if (method !== "GET" && method !== "HEAD") { - const csrf = getCookie("csrf_token"); - if (csrf) opts.headers["X-CSRF-Token"] = csrf; - } - - const res = await fetch(GATEWAY_BASE + path, opts); - return res; -} - -async function apiJson(path, opts) { - const res = await api(path, opts); - let body = null; - try { body = await res.json(); } catch (_) { /* no body */ } - if (!res.ok) { - const msg = body?.detail?.message || body?.detail || res.statusText; - const error = new Error(typeof msg === "string" ? msg : JSON.stringify(msg)); - error.status = res.status; - error.code = body?.detail?.code; - throw error; - } - return body; -} +const { createApiClient, decodeResponse } = typeof require === "function" ? require("./api-client.js") : globalThis.JobScoutApi; +const { TrackerPager } = typeof require === "function" ? require("./tracker-state.js") : globalThis.JobScoutTracker; +const requestApi = createApiClient({ base: GATEWAY_BASE, fetcher: (...args) => fetch(...args), cookie: () => document.cookie }); +async function api(path, options) { return requestApi(path, options); } +async function apiJson(path, options) { return decodeResponse(await api(path, options)); } function show(view) { for (const v of ["authView", "chatView"]) { @@ -335,6 +192,9 @@ function setupAuthForm() { lastBaseContext = null; savedMatchCandidates = { threadId: null, candidates: [] }; trackerRows = []; + trackerPager.reset(); + trackerStageFilter = null; + trackerPageLoading = false; $("trackerLoading")?.classList.add("hidden"); $("trackerPanel")?.setAttribute("aria-busy", "false"); $("trackerUnreadCount").textContent = "0"; @@ -376,6 +236,8 @@ let chatTimerHandle = null; let pendingFile = null; let currentMode = "tracker"; let trackerRows = []; +const trackerPager = new TrackerPager(); +let trackerPageLoading = false; let trackerBusy = false; let trackerStages = []; let composerBusy = false; @@ -861,7 +723,7 @@ function setMode(mode, { restoreThread = true } = {}) { conversationOpportunityId = selectedOpportunityId; if (restore) restoreOpportunityThread(); } - if ($("chatStatus")) $("chatStatus").textContent = currentMode === "tracker" ? `${trackerRows.length} 条投递记录` : chatStatusText; + if ($("chatStatus")) $("chatStatus").textContent = currentMode === "tracker" ? `${trackerPager.data?.total ?? trackerRows.length} 条投递记录` : chatStatusText; } async function loadLarkStatus() { @@ -1012,7 +874,7 @@ function showTrackerBatchResult(rows) { if (!dialog) return; const latest = [...rows].reverse().find((row) => row.checked_at) || rows[0]; if (!latest) return; - notifyTrackerResult(latest, `刷新完成,当前共 ${rows.length} 条投递记录;最新结果已更新到表格。`); + notifyTrackerResult(latest, `刷新完成,当前共 ${trackerPager.data?.total ?? rows.length} 条投递记录;最新结果已更新到表格。`); } function setTrackerBusy(busy) { @@ -1038,38 +900,45 @@ function renderTrackerRows() { const body = $("trackerTableBody"); if (!body) return; body.replaceChildren(); - const summary = trackerSummary(trackerRows); + const summary = trackerPager.data?.summary || trackerSummary(trackerRows); for (const [key, id] of Object.entries({ total: "trackerTotalCount", active: "trackerActiveCount", review: "trackerReviewCount", offers: "trackerOfferCount" })) { if ($(id)) $(id).textContent = String(summary[key]); } - const options = trackerStageFilterOptions(trackerRows, trackerStages); + const counts = trackerPager.data?.stages || []; + const options = [...counts].sort((a, b) => { const order = label => trackerStages.includes(label) ? trackerStages.indexOf(label) : trackerStages.length; return order(a.label) - order(b.label); }); if (trackerStageFilter !== null && !options.some((option) => option.label === trackerStageFilter)) { options.push({ label: trackerStageFilter, count: 0 }); } const visibleRows = filterTrackerRows(trackerRows, trackerStageFilter); - const hasRows = trackerRows.length > 0; + const hasRows = summary.total > 0; $("trackerEmpty")?.classList.toggle("hidden", hasRows); $("trackerNoResults")?.classList.toggle("hidden", !hasRows || visibleRows.length > 0); $("trackerTableWrap")?.classList.toggle("hidden", visibleRows.length === 0); $("trackerFilterBar")?.classList.toggle("hidden", !hasRows); - if ($("trackerFilterSummary")) $("trackerFilterSummary").textContent = `显示 ${visibleRows.length} / ${trackerRows.length} 条`; + if ($("trackerFilterSummary")) $("trackerFilterSummary").textContent = `本页 ${visibleRows.length} 条 / 共 ${trackerPager.data?.filtered_total ?? 0} 条`; const tags = $("trackerFilterTags"); if (tags) { tags.replaceChildren(); - for (const option of [{ label: "全部", count: trackerRows.length, value: null }, ...options.map((item) => ({ ...item, value: item.label }))]) { + for (const option of [{ label: "全部", count: summary.total, value: null }, ...options.map((item) => ({ ...item, value: item.label }))]) { const tag = trackerElement("button", "tracker-filter-tag"); tag.type = "button"; tag.setAttribute("aria-pressed", String(trackerStageFilter === option.value)); tag.append(trackerElement("span", "", option.label), trackerElement("span", "tracker-filter-count", String(option.count))); - tag.addEventListener("click", () => { + tag.addEventListener("click", async () => { trackerStageFilter = option.value; - renderTrackerRows(); + trackerPager.filter(option.value); + await loadTrackerApplications(); tags.querySelector('[aria-pressed="true"]')?.focus(); }); tags.append(tag); } } + $("trackerPagination")?.classList.toggle("hidden", !hasRows); + if ($("trackerPageLabel")) $("trackerPageLabel").textContent = `第 ${trackerPager.page} 页 · 每页 ${trackerPager.limit} 条`; + if ($("trackerPreviousPage")) $("trackerPreviousPage").disabled = trackerPageLoading || trackerPager.page === 1; + if ($("trackerNextPage")) $("trackerNextPage").disabled = trackerPageLoading || !trackerPager.data?.has_more; + for (const storedRow of visibleRows) { const row = trackerDisplayRow(storedRow); const presentation = trackerRowPresentation(row); @@ -1161,7 +1030,7 @@ function renderTrackerRows() { removeButton.type = "button"; removeButton.addEventListener("click", async () => { if (!confirm(`确定删除 ${row.company} - ${row.role}?`)) return; - try { await apiJson(`/api/jobscout/tracker/applications/${row.id}`, { method: "DELETE" }); trackerRows = trackerRows.filter((item) => item.id !== row.id); renderTrackerRows(); await loadOpportunities(); } + try { await apiJson(`/api/jobscout/tracker/applications/${row.id}`, { method: "DELETE" }); await loadTrackerApplications(); await loadOpportunities(); } catch (error) { showTrackerError(error.message || String(error)); } }); actionCell.append(removeButton); @@ -1184,30 +1053,37 @@ async function patchTrackerRow(id, changes) { try { const updated = await apiJson(`/api/jobscout/tracker/applications/${id}`, { method: "PATCH", json: changes }); trackerRows = trackerRows.map((row) => row.id === id ? updated : row); - renderTrackerRows(); + await loadTrackerApplications(); } catch (error) { showTrackerError(error.message || String(error)); await loadTrackerApplications(); } } -async function loadTrackerApplications() { +async function loadTrackerApplications({ signal } = {}) { const current = beginViewRequest("tracker"); + trackerPageLoading = true; + renderTrackerRows(); showTrackerError(); $("trackerLoading")?.classList.remove("hidden"); $("trackerPanel")?.setAttribute("aria-busy", "true"); try { - const rows = await apiJson("/api/jobscout/tracker/applications"); + const data = await apiJson(trackerPager.url(), { signal }); if (!current()) return; - trackerRows = Array.isArray(rows) ? rows : []; + trackerRows = trackerPager.accept(data); + if (!trackerRows.length && trackerPager.previous()) return await loadTrackerApplications({ signal }); if (!trackerAddFormInitialized) { - setTrackerAddFormOpen(trackerRows.length === 0); + setTrackerAddFormOpen(trackerPager.data.total === 0); trackerAddFormInitialized = true; } renderTrackerRows(); - if (currentMode === "tracker" && $("chatStatus")) $("chatStatus").textContent = `${trackerRows.length} 条投递记录`; + if (currentMode === "tracker" && $("chatStatus")) $("chatStatus").textContent = `${trackerPager.data?.total ?? trackerRows.length} 条投递记录`; loadTrackerNotifications().catch(() => {}); } catch (error) { - if (current()) showTrackerError(error.message || String(error)); + if (current()) { + trackerPager.reject(); trackerStageFilter = trackerPager.stage; + showTrackerError(error.message || String(error)); + } } finally { if (current()) { + trackerPageLoading = false; renderTrackerRows(); $("trackerLoading")?.classList.add("hidden"); $("trackerPanel")?.setAttribute("aria-busy", "false"); } @@ -1381,14 +1257,14 @@ async function refreshTrackerRow(applicationId) { setTrackerBusy(true); showTrackerError(); const current = trackerRows.find((row) => row.id === applicationId); - const knownIds = new Set(trackerRows.map((row) => row.id)); + const knownCount = trackerPager.data?.total || 0; setTrackerProgress(`正在检查 ${current?.company || "该岗位"};如需登录会弹出浏览器窗口`, 0, 1, true); try { const outcome = await apiJson(`/api/jobscout/tracker/applications/${applicationId}/refresh`, { method: "POST", signal: job.controller.signal, timeoutMs: 600000 }); - const rows = await apiJson("/api/jobscout/tracker/applications", { signal: job.controller.signal }); if (!jobIsCurrent(job) || job.controller.signal.aborted) return null; - trackerRows = rows; - const added = trackerRows.filter((row) => !knownIds.has(row.id)).length; + await loadTrackerApplications({ signal: job.controller.signal }); + if (!jobIsCurrent(job) || job.controller.signal.aborted) return null; + const added = Math.max(0, (trackerPager.data?.total || 0) - knownCount); renderTrackerRows(); loadOpportunities().catch((error) => console.error("loadOpportunities failed", error)); setTrackerProgress(outcome.skipped ? "已跳过重复或失效的检查" : "检查完成", 1, 1, true); @@ -1512,7 +1388,9 @@ function setupTracker() { setTrackerAddFormOpen(!trackerAddFormOpen); if (trackerAddFormOpen) $("trackerAddForm")?.elements.company.focus(); }); - $("trackerClearFilter")?.addEventListener("click", () => { trackerStageFilter = null; renderTrackerRows(); }); + $("trackerClearFilter")?.addEventListener("click", () => { trackerStageFilter = null; trackerPager.filter(null); loadTrackerApplications(); }); + $("trackerNextPage")?.addEventListener("click", () => { if (trackerPager.next()) loadTrackerApplications(); }); + $("trackerPreviousPage")?.addEventListener("click", () => { if (trackerPager.previous()) loadTrackerApplications(); }); $("trackerRefreshAllBtn")?.addEventListener("click", refreshAllTrackerRows); $("trackerStagesBtn")?.addEventListener("click", () => { const panel = $("trackerStageEditor"); @@ -1530,7 +1408,7 @@ function setupTracker() { try { setTrackerBusy(true); showTrackerError(); const row = await apiJson("/api/jobscout/tracker/applications", { method: "POST", json: { company: data.get("company"), url: data.get("url") } }); - trackerRows = [...trackerRows.filter((item) => item.id !== row.id), row]; renderTrackerRows(); form.reset(); + trackerPager.filter(null); trackerStageFilter = null; await loadTrackerApplications(); form.reset(); const refreshed = await refreshTrackerRow(row.id); setTrackerAddFormOpen(false); const opportunity = selectedOpportunity(); @@ -2004,168 +1882,6 @@ async function streamRunToText(threadId, messageText, filesMeta, onProgress, job return extractLastVisibleAiText(lastMessages, runMode); } -function jobScoutRunContext(mode, contextRef = null) { - return { - jobscout_mode: mode === "match" ? "base_match" : "interview_prep", - jobscout_evidence_version: 2, - ...(mode === "match" && typeof contextRef === "string" && contextRef ? { jobscout_base_context_ref: contextRef } : {}), - }; -} - -/** Canonical display text for both values streams and persisted history. - * Backend-filtered content also feeds renderReport's print/download actions. - * Old reports cannot be retroactively verified without their original tools. */ -function guardedMessageText(message, mode = "prep") { - if (message?.additional_kwargs?.hide_from_ui) return ""; - if (message?.type === "tool" && message.name !== "ask_clarification") return ""; - if (message?.tool_calls?.length) return ""; - const text = contentToText(message?.content).trim(); - if (!text || message?.type === "tool" && message.name === "ask_clarification") return text; - const stamp = message?.additional_kwargs?.jobscout_evidence; - const route = message?.additional_kwargs?.jobscout_route; - if (route?.version === 3 && typeof route.run_id === "string" && route.run_id && - typeof route.in_scope === "boolean" && Array.isArray(route.missing_fields) && - (!route.in_scope || route.missing_fields.length > 0)) return text; - if (stamp?.version === 2 && stamp.mode === (mode === "match" ? "base_match" : "interview_prep") && - typeof stamp.run_id === "string" && stamp.run_id && - Number.isInteger(stamp.accepted_count) && stamp.accepted_count >= 0 && - Number.isInteger(stamp.removed_count) && stamp.removed_count >= 0) return text; - return "这份内容尚无本次运行的结构化证据与来源校验记录,暂不展示或导出。旧报告需要重新生成;新报告请先启用 JobScout 来源校验中间件。"; -} - -function contentToText(content) { - if (typeof content === "string") return content; - if (Array.isArray(content)) { - return content - .map((part) => (typeof part === "string" ? part : part?.text || "")) - .filter(Boolean) - .join("\n"); - } - return ""; -} - -function extractLastVisibleAiText(messages, mode = "prep") { - // `ask_clarification` is `return_direct=True`: LangGraph ends the run with - // its ToolMessage (type "tool") as the final state, and the model's own - // preceding AIMessage carries the tool *call*, not the question text the - // user is meant to see. Only checking type "ai" here silently swallowed - // every clarification question — a real bug found via a live user report, - // not code review (Gateway logs proved the server side worked fine; the - // Gateway's actual response just wasn't the shape this function assumed). - for (let i = messages.length - 1; i >= 0; i--) { - const m = messages[i]; - if (m?.type === "human") break; - if (m?.additional_kwargs?.hide_from_ui) continue; - if (m?.type === "ai" || m?.type === "tool") { - const text = guardedMessageText(m, mode); - if (text) return text; - } - } - return ""; -} - -/** A real report has these as standalone section-title lines. The preferred - * shape is a Markdown heading, but live models may emit a numbered outline - * (`1) 公司速览`, `2) 岗位拆解(...)`) while still returning the complete - * document. A prose mention must NOT count, or the UI renders print/download - * buttons for an offer to create a report rather than the report itself. */ -function looksLikeReport(text) { - const sectionPrefix = "(?:#{1,3}\\s+|\\d+\\s*[)).、]\\s*)?"; - const sectionSuffix = "(?:[((][^\\r\\n]*[))])?\\s*$"; - const hitCount = (markers) => markers.filter((m) => - new RegExp(`^${sectionPrefix}${m}${sectionSuffix}`, "m").test(text) - ).length; - return hitCount(["公司速览", "岗位拆解", "面试题预测"]) >= 2 || - hitCount(["候选人画像", "推荐岗位", "匹配依据", "风险与数据边界"]) >= 3; -} - -/** Keep the JobScout document contract stable when a model appends generic - * coaching chapters of its own. This is deliberately conservative: only - * known top-level drift is removed, so numbered questions and preparation - * steps inside an allowed section are never mistaken for new chapters. */ -function sanitizeJobScoutReportMarkdown(markdown) { - const source = String(markdown || ""); - const matchingMode = source.includes("简历 × 飞书岗位匹配报告") || - ["候选人画像", "推荐岗位", "匹配依据", "风险与数据边界"].filter((s) => source.includes(s)).length >= 3; - const allowedSections = matchingMode - ? ["候选人画像", "推荐岗位", "匹配依据", "风险与数据边界"] - : ["公司速览", "岗位拆解", "面试题预测", "差距分析", "证据边界与后续建议"]; - const unwantedSections = [ - "使用说明", - "一周上岸计划", - "上岸计划", - "冲刺计划", - "话术模板", - "专项准备资料", - "清单与打卡", - "执行凭据", - "工具收据", - "sources", - ]; - const startsWithAny = (value, candidates) => { - const normalized = value.trim().toLowerCase(); - return candidates.some((candidate) => normalized.startsWith(candidate.toLowerCase())); - }; - - let keep = true; - const keptLines = []; - for (const line of source.split(/\r?\n/)) { - const trimmed = line.trim(); - const numbered = trimmed.match(/^\d+\s*[)).、]\s*(.+)$/); - const levelTwo = trimmed.match(/^##(?!#)\s+(.+)$/); - const bareStop = /^(?:执行凭据(?:(工具收据))?|工具收据|sources)$/i.test(trimmed); - const candidate = numbered?.[1] || levelTwo?.[1] || (bareStop ? trimmed : ""); - - if (candidate) { - if (startsWithAny(candidate, allowedSections)) { - keep = true; - } else if (levelTwo || bareStop || startsWithAny(candidate, unwantedSections)) { - keep = false; - } - } - - if (keep) keptLines.push(line); - } - - return keptLines.join("\n").trimEnd() + "\n"; -} - -/** Normalize common model formatting drift for the rendered/printed view. - * The sanitized Markdown is also used for download. */ -function normalizeReportMarkdownForRender(markdown) { - let seenNonEmpty = false; - return markdown - .split(/\r?\n/) - .map((line) => { - const trimmed = line.trim(); - if (!trimmed) return line; - - if (!seenNonEmpty) { - seenNonEmpty = true; - if (!trimmed.startsWith("#") && (trimmed.includes("面试准备包") || trimmed.includes("岗位匹配报告"))) { - return `# ${trimmed}`; - } - } - - const numberedSection = trimmed.match( - /^\d+\s*[)).、]\s*(公司速览|岗位拆解|面试题预测|差距分析|候选人画像|推荐岗位|匹配依据|风险与数据边界)(.*)$/ - ); - if (numberedSection) { - return `## ${numberedSection[1]}${numberedSection[2]}`; - } - - if ( - !trimmed.startsWith("#") && - /^(?:业务与产品|近期动态|融资\s*[\//]\s*规模(?:(.*))?|公司技术栈核对(?:(.*))?|技术\s*[\//]\s*岗位题|行为题|证据清单(?:(.*))?)$/.test(trimmed) - ) { - return `### ${trimmed}`; - } - - return line; - }) - .join("\n"); -} - /** Run one turn (any user message: the opening ask or a mid-conversation * reply) and either post the assistant's reply as a bubble — conversation * continues via the composer, same as any chat turn — or, once the @@ -2587,119 +2303,6 @@ function renderHistoryMessages(messages) { // report template uses: #/##/### headings, **bold**, [text](url) links, // bullet/numbered lists, "|" tables, "> " blockquotes, and paragraphs. -function escapeHtml(s) { - return s.replace(/&/g, "&").replace(//g, ">"); -} - -function inlineMd(s) { - // Code-rendered evidence escapes Markdown punctuation. Preserve those - // characters as text before interpreting actual formatting and links. - let out = escapeHtml(s) - .replace(/\\([\\`*_{}\[\]()#+.!|])/g, (_, char) => "&#" + char.charCodeAt(0) + ";") - .replace(/\\(<|>)/g, "$1"); - out = out.replace(/\[([^\]]+)\]\(<(https?:\/\/[^\s]+?)>\)/g, '$1'); - out = out.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, '$1'); - out = out.replace(/\*\*([^*]+)\*\*/g, "$1"); - out = out.replace(/`([^`]+)`/g, "$1"); - return out; -} - -function markdownTableCells(row) { - const cells = [""]; - const text = row.replace(/^\||\|$/g, ""); - for (let i = 0; i < text.length; i++) { - if (text[i] === "\\" && i + 1 < text.length) { - cells[cells.length - 1] += text[i] + text[++i]; - } else if (text[i] === "|") cells.push(""); - else cells[cells.length - 1] += text[i]; - } - return cells.map(cell => cell.trim()); -} - -function markdownToHtml(md) { - const lines = md.replace(/\r\n/g, "\n").split("\n"); - let html = ""; - let i = 0; - let inList = null; // 'ul' | 'ol' | null - - function closeList() { - if (inList) { html += ``; inList = null; } - } - - while (i < lines.length) { - const line = lines[i]; - - if (/^\s*$/.test(line)) { closeList(); i++; continue; } - - const heading = line.match(/^(#{1,4})\s+(.*)$/); - if (heading) { - closeList(); - const level = heading[1].length; - html += `${inlineMd(heading[2])}`; - i++; continue; - } - - if (/^>\s?/.test(line)) { - closeList(); - const quoteLines = []; - while (i < lines.length && /^>\s?/.test(lines[i])) { - quoteLines.push(lines[i].replace(/^>\s?/, "")); - i++; - } - html += `
${inlineMd(quoteLines.join(" "))}
`; - continue; - } - - if (/^\s*\|.*\|\s*$/.test(line)) { - closeList(); - const rows = []; - while (i < lines.length && /^\s*\|.*\|\s*$/.test(lines[i])) { - rows.push(lines[i].trim()); - i++; - } - if (rows.length >= 2 && /^\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)+\|?$/.test(rows[1])) { - const headCells = markdownTableCells(rows[0]); - html += "" + headCells.map((c) => ``).join("") + ""; - for (let r = 2; r < rows.length; r++) { - const cells = markdownTableCells(rows[r]); - html += "" + cells.map((c) => ``).join("") + ""; - } - html += "
${inlineMd(c)}
${inlineMd(c)}
"; - } else { - rows.forEach((r) => { html += `

${inlineMd(r)}

`; }); - } - continue; - } - - const bullet = line.match(/^\s*[-*]\s+(.*)$/); - if (bullet) { - if (inList !== "ul") { closeList(); html += "