feat: add compliant data connectors and lineage

- add authorized nine-source crawler connectors with security controls and packaged schemas
- add the school internship contract to the vendor data standard center
- add bidirectional campus data-to-report lineage with gap ownership and remediation

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
lofyer
2026-08-24 16:28:01 +08:00
co-authored by factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
parent 92515928d9
commit 8dff9847e3
46 changed files with 6145 additions and 55 deletions
+7
View File
@@ -0,0 +1,7 @@
.pytest_cache/
.venv/
__pycache__/
*.egg-info/
build/
dist/
output/
+108
View File
@@ -0,0 +1,108 @@
# 产教数据连接器运行时
本目录实现厂商端 9 个目标来源的统一采集骨架。所有外部来源默认禁止网络访问,只有完成书面授权、robots、域名白名单、技术联系人和请求限额配置后,命令行才允许发起请求。
逐来源决策见 [`../../docs/crawler-source-implementation.md`](../../docs/crawler-source-implementation.md),统一字段见 [`../demo/schemas/`](../demo/schemas/)。
## 能力
- 7 个外部来源的具体连接器类;
- 2 个校内来源的租户隔离文件连接器;
- 官方 API / 授权 JSON、NDJSON、CSV 数据包接入;
- 获准公开页面的 robots、域名、重定向、限速、响应大小和 DNS 重绑定保护;
- Schema.org `JobPosting` 与结构化 feed 解析;
- `JOB-SIGNAL V3.0`、`SCHOOL-EMPLOYMENT V2.2`、`SCHOOL-INTERNSHIP V2.1` 校验;
- Raw 快照、标准记录、隔离记录和运行摘要输出;
- URL 密钥脱敏、敏感校内字段拒绝和确定性幂等标识。
不支持验证码、登录自动化、Cookie 复用、代理池、浏览器指纹伪装、私有接口逆向或任何访问限制绕过。
## 安装与测试
```bash
cd admin-tools/crawler
python -m venv .venv
.venv/bin/pip install -e '.[test]'
.venv/bin/pytest
```
当前环境已安装依赖时,也可直接运行:
```bash
PYTHONPATH=src python -m edu_crawler list-sources
PYTHONPATH=src python -m pytest
```
## 离线授权数据包
商业来源推荐先使用离线授权数据包验证字段映射:
```bash
PYTHONPATH=src python -m edu_crawler run \
--source BOSS \
--input /secure/inbox/boss-authorized.ndjson \
--authorization-ref CONTRACT-2026-001 \
--task-id JOB-20260824-BOSS-001 \
--data-version V2026.08.24 \
--output ./output
```
本地输入不会发起网络请求,但 7 个外部来源仍要求授权编号。
## 获准网络来源
只有部署审批完成后才能显式增加 `--allow-network`:
```bash
PYTHONPATH=src python -m edu_crawler run \
--source BRZP \
--input 'https://approved.example/jobs-feed.json' \
--approved-host approved.example \
--authorization-ref AUTH-BRZP-2026-001 \
--contact crawler-ops@example.edu.cn \
--allow-network \
--task-id JOB-20260824-BRZP-001 \
--data-version V2026.08.24 \
--max-pages 5 \
--max-records 1000 \
--output ./output
```
- `--approved-host` 必须来自来源审批配置,不能使用 IP;
- `--approved-host` 还必须存在于对应连接器的版本化 allowlist;新增合作 API 域名需要代码评审;
- API Bearer Token 通过 `--bearer-token-env ENV_NAME` 引用环境变量;
- Bearer Token 任务只允许一个审批主机,Token 不发送给 robots.txt;
- 命令输出、任务摘要和 Raw 元数据均不保存 Token;
- 实际请求间隔不会低于来源策略的最低值。
## 校内来源
校内文件必须先在学校侧完成学生令牌化:
```bash
PYTHONPATH=src python -m edu_crawler run \
--source SCHOOL_EMPLOYMENT \
--input /secure/inbox/employment.ndjson \
--authorization-ref DPA-TENANT-HZVTC-2026-001 \
--tenant-id TENANT-HZVTC \
--task-id JOB-20260824-SCHOOL-EMP-001 \
--data-version SCH-EMP-20260824-01 \
--output ./output
```
如果输入包含 `student_name`、`student_id`、身份证、手机号、邮箱等禁止字段,任务会将记录写入隔离区,不会进入标准层。
## 输出
```text
外部来源:output/<SOURCE>/<TASK-ID>/
校内来源:output/<SOURCE>/<TENANT-ID>/<TASK-ID>/
├── raw/
│ ├── <sha256>.<json|html|bin>
│ └── <sha256>.metadata.json
├── standard.ndjson
├── quarantine.ndjson
└── summary.json
```
任务目录不可覆盖。重跑必须使用新任务号,业务记录的 `record_id` 和 `content_hash` 保持确定性。
+32
View File
@@ -0,0 +1,32 @@
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "edu-industry-crawler"
version = "0.1.0"
description = "Policy-gated recruitment and school data connectors for the edu-industry platform"
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"beautifulsoup4>=4.12,<5",
"httpcore>=1,<2",
"httpx>=0.27,<1",
"jsonschema>=4.20,<5",
]
[project.optional-dependencies]
test = ["pytest>=8,<10"]
[project.scripts]
edu-industry-crawler = "edu_crawler.cli:main"
[tool.setuptools.packages.find]
where = ["src"]
[tool.setuptools.package-data]
edu_crawler = ["schemas/*.json"]
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-q"
@@ -0,0 +1,7 @@
"""Policy-gated data connectors for the edu-industry platform."""
from .policies import SOURCE_POLICIES, SourcePolicy
from .runner import CrawlerRunner, RunConfig
__all__ = ["CrawlerRunner", "RunConfig", "SOURCE_POLICIES", "SourcePolicy"]
__version__ = "0.1.0"
@@ -0,0 +1,3 @@
from .cli import main
raise SystemExit(main())
@@ -0,0 +1,95 @@
"""Command-line interface."""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
from .errors import CrawlerError
from .policies import SOURCE_POLICIES
from .runner import CrawlerRunner, RunConfig
def parser() -> argparse.ArgumentParser:
root = argparse.ArgumentParser(prog="edu-industry-crawler")
commands = root.add_subparsers(dest="command", required=True)
commands.add_parser("list-sources", help="List registered source policies")
run = commands.add_parser("run", help="Run one policy-gated connector task")
run.add_argument("--source", required=True, choices=tuple(SOURCE_POLICIES))
run.add_argument("--input", required=True, dest="input_location")
run.add_argument("--task-id", required=True)
run.add_argument("--data-version", required=True)
run.add_argument(
"--output", type=Path, default=Path("./output"), dest="output_root"
)
run.add_argument("--authorization-ref", dest="authorization_reference")
run.add_argument("--allow-network", action="store_true")
run.add_argument(
"--approved-host", action="append", default=[], dest="approved_hosts"
)
run.add_argument("--contact")
run.add_argument("--bearer-token-env")
run.add_argument("--max-pages", type=int)
run.add_argument("--max-records", type=int)
run.add_argument("--default-region-code")
run.add_argument("--tenant-id")
return root
def _list_sources() -> int:
for policy in SOURCE_POLICIES.values():
print(
json.dumps(
{
"code": policy.code,
"name": policy.display_name,
"kind": policy.kind,
"access_mode": policy.access_mode,
"target_schema": policy.target_schema,
"network_default": "disabled",
"authorization_required": policy.authorization_required,
},
ensure_ascii=False,
sort_keys=True,
)
)
return 0
def main(argv: list[str] | None = None) -> int:
arguments = parser().parse_args(argv)
if arguments.command == "list-sources":
return _list_sources()
config = RunConfig(
source_code=arguments.source,
input_location=arguments.input_location,
task_id=arguments.task_id,
data_version=arguments.data_version,
output_root=arguments.output_root,
authorization_reference=arguments.authorization_reference,
allow_network=arguments.allow_network,
approved_hosts=tuple(arguments.approved_hosts),
contact=arguments.contact,
bearer_token_env=arguments.bearer_token_env,
max_pages=arguments.max_pages,
max_records=arguments.max_records,
default_region_code=arguments.default_region_code,
tenant_id=arguments.tenant_id,
)
try:
output = CrawlerRunner(config).run()
except CrawlerError as exc:
print(
json.dumps(
{"status": "FAILED", "error_code": exc.code, "error": str(exc)},
ensure_ascii=False,
),
file=sys.stderr,
)
return 2
print(
json.dumps({"status": "COMPLETED", "output": str(output)}, ensure_ascii=False)
)
return 0
@@ -0,0 +1,5 @@
"""Connector registry."""
from .sources import CONNECTOR_TYPES, connector_for
__all__ = ["CONNECTOR_TYPES", "connector_for"]
@@ -0,0 +1,34 @@
"""Connector service-provider interface."""
from __future__ import annotations
from abc import ABC, abstractmethod
from ..models import ParsedItem, SourcePolicy
class BaseConnector(ABC):
def __init__(self, policy: SourcePolicy) -> None:
self.policy = policy
def describe(self) -> dict:
return {
"source_code": self.policy.code,
"display_name": self.policy.display_name,
"kind": self.policy.kind,
"access_mode": self.policy.access_mode,
"connector_version": self.policy.connector_version,
"target_schema": self.policy.target_schema,
"network_default": "disabled",
}
@abstractmethod
def normalize(
self,
item: ParsedItem,
*,
task_id: str,
data_version: str,
default_region_code: str | None,
) -> dict:
"""Map one parsed source item to the target contract."""
@@ -0,0 +1,447 @@
"""External recruitment source normalization."""
from __future__ import annotations
import re
import uuid
from datetime import datetime
from typing import ClassVar
from urllib.parse import urlsplit
from ..errors import SourceStructureChanged
from ..hashing import canonical_content_hash
from ..models import ParsedItem
from ..parsing import strip_markup
from ..security import host_allowed, redact_url, safe_record_reference
from .base import BaseConnector
RECORD_NAMESPACE = uuid.UUID("4afad1d4-4373-43b0-b722-77b7b3aab62c")
CONTACT_RE = re.compile(
r"(?:[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}|(?<!\d)(?:\+?86[- ]?)?1[3-9]\d{9}(?!\d))"
)
def _path(record: dict, dotted: str) -> object:
value: object = record
for part in dotted.split("."):
if not isinstance(value, dict):
return None
value = value.get(part)
return value
def _first(record: dict, aliases: tuple[str, ...]) -> object:
for alias in aliases:
value = _path(record, alias)
if value not in (None, "", [], {}):
return value
return None
def _identifier(value: object) -> str:
return safe_record_reference(value) or ""
def _scalar(value: object) -> object:
if isinstance(value, list):
return value[0] if value else None
return value
def _number(value: object) -> float | None:
value = _scalar(value)
if value in (None, ""):
return None
if isinstance(value, dict):
value = value.get("value") or value.get("minValue") or value.get("maxValue")
text = str(value).replace(",", "").strip()
match = re.search(r"-?\d+(?:\.\d+)?", text)
return float(match.group()) if match else None
def _integer(value: object) -> int | None:
number = _number(value)
return int(number) if number is not None else None
def _string_list(value: object) -> list[str]:
if value in (None, ""):
return []
values = value if isinstance(value, list) else re.split(r"[|,,;/;]+", str(value))
cleaned = [strip_markup(item) for item in values]
return list(dict.fromkeys(item for item in cleaned if item))[:200]
def _datetime(value: object) -> str | None:
value = _scalar(value)
if not value:
return None
text = str(value).strip().replace("Z", "+00:00")
try:
parsed = datetime.fromisoformat(text)
except ValueError:
return None
if parsed.tzinfo is None:
return None
return parsed.isoformat(timespec="seconds")
def _status(value: object) -> str:
value = _scalar(value)
normalized = str(value or "").strip().lower()
if normalized in {"active", "online", "open", "published", "招聘中", "有效"}:
return "active"
if normalized in {"inactive", "offline", "closed", "下线", "失效"}:
return "inactive"
if normalized in {"expired", "过期", "已结束"}:
return "expired"
return "unknown"
def _employment_type(value: object) -> str:
value = _scalar(value)
normalized = str(value or "").strip().lower()
mappings = {
"full_time": "full_time",
"full-time": "full_time",
"全职": "full_time",
"internship": "internship",
"实习": "internship",
"part_time": "part_time",
"part-time": "part_time",
"兼职": "part_time",
"contract": "contract",
"合同": "contract",
"campus": "campus",
"校招": "campus",
}
return mappings.get(normalized, "other")
def _education(value: object) -> str | None:
value = _scalar(value)
normalized = str(value or "").strip().lower()
mappings = {
"不限": "none",
"none": "none",
"中专": "secondary",
"高中": "secondary",
"secondary": "secondary",
"职高": "vocational",
"vocational": "vocational",
"大专": "associate",
"专科": "associate",
"associate": "associate",
"本科": "bachelor",
"bachelor": "bachelor",
"硕士": "master",
"master": "master",
"博士": "doctor",
"doctor": "doctor",
"unknown": "unknown",
}
return mappings.get(normalized)
def _salary_period(value: object) -> str | None:
normalized = str(_scalar(value) or "").strip().lower()
mappings = {
"month": "month",
"monthly": "month",
"月": "month",
"月薪": "month",
"year": "year",
"yearly": "year",
"年": "year",
"年薪": "year",
"day": "day",
"daily": "day",
"日": "day",
"日薪": "day",
"hour": "hour",
"hourly": "hour",
"小时": "hour",
"时薪": "hour",
"negotiable": "negotiable",
"面议": "negotiable",
"unknown": "unknown",
}
return mappings.get(normalized)
def _schema_org_salary(
record: dict,
) -> tuple[float | None, float | None, str | None, str | None]:
salary = record.get("baseSalary")
if not isinstance(salary, dict):
return None, None, None, None
currency = strip_markup(salary.get("currency")) or None
value = salary.get("value")
if isinstance(value, dict):
minimum = _number(value.get("minValue") or value.get("value"))
maximum = _number(value.get("maxValue") or value.get("value"))
unit = strip_markup(value.get("unitText")).lower()
else:
minimum = maximum = _number(value)
unit = ""
period = {
"month": "month",
"monthly": "month",
"year": "year",
"yearly": "year",
"day": "day",
"daily": "day",
"hour": "hour",
"hourly": "hour",
}.get(unit)
return minimum, maximum, currency, period
def _monthly_salary(value: float | None, period: str | None) -> float | None:
if value is None:
return None
factors = {
"month": 1.0,
"year": 1 / 12,
"day": 21.75,
"hour": 174.0,
}
factor = factors.get(period or "month")
return round(value * factor, 2) if factor is not None else value
class ExternalJobConnector(BaseConnector):
aliases: ClassVar[dict[str, tuple[str, ...]]] = {}
common_aliases: ClassVar[dict[str, tuple[str, ...]]] = {
"source_record_id": (
"source_record_id",
"id",
"identifier",
"job_id",
"jobId",
"@id",
),
"source_url": ("source_url", "url", "job_url", "jobUrl"),
"job_title_raw": (
"job_title_raw",
"title",
"name",
"job_title",
"jobName",
"position_name",
),
"job_description": (
"job_description",
"description",
"responsibilities",
"job_description_html",
),
"enterprise_name": (
"enterprise_name",
"company_name",
"companyName",
"hiringOrganization.name",
"organization.name",
),
"region_code": (
"region_code",
"city_code",
"cityCode",
"area_code",
"address.region_code",
),
"workplace": (
"workplace",
"jobLocation.address.streetAddress",
"jobLocation.address.addressLocality",
"address",
),
"employment_type": ("employment_type", "employmentType", "job_type", "jobType"),
"published_at": ("published_at", "datePosted", "publish_time", "publishTime"),
"status": ("status", "job_status", "jobStatus"),
"recruitment_count": ("recruitment_count", "headcount", "number_of_positions"),
"education_level": ("education_level", "education", "educationRequirements"),
"experience_min_years": (
"experience_min_years",
"experience_years",
"experienceRequirements",
),
"salary_min": ("salary_min", "salaryMin", "min_salary"),
"salary_max": ("salary_max", "salaryMax", "max_salary"),
"salary_currency": ("salary_currency", "salaryCurrency", "currency"),
"salary_period": ("salary_period", "salaryPeriod"),
"skills": ("skills", "skill_tags", "skillTags"),
"benefits": ("benefits", "jobBenefits", "benefit_tags"),
"keywords": ("keywords", "tags"),
}
def _aliases(self, field: str) -> tuple[str, ...]:
return (*self.aliases.get(field, ()), *self.common_aliases[field])
def normalize(
self,
item: ParsedItem,
*,
task_id: str,
data_version: str,
default_region_code: str | None,
) -> dict:
source = item.value
declared_source = strip_markup(source.get("source_platform")).upper()
if declared_source and declared_source != self.policy.code:
raise SourceStructureChanged(
"Source record platform does not match the selected connector"
)
source_record_id = _identifier(
_first(source, self._aliases("source_record_id"))
)
source_url = strip_markup(_first(source, self._aliases("source_url")))
if not source_url and urlsplit(item.source_url).scheme in {"http", "https"}:
source_url = item.source_url
title = strip_markup(_first(source, self._aliases("job_title_raw")))
description = strip_markup(_first(source, self._aliases("job_description")))
enterprise = strip_markup(_first(source, self._aliases("enterprise_name")))
region_code = (
strip_markup(_first(source, self._aliases("region_code")))
or default_region_code
or ""
)
if not source_record_id:
raise SourceStructureChanged("Source record has no stable source_record_id")
if not source_url:
raise SourceStructureChanged("Source record has no traceable source_url")
source_parts = urlsplit(source_url)
if source_parts.scheme not in {"http", "https"} or not source_parts.hostname:
raise SourceStructureChanged("Source record URL must use HTTP or HTTPS")
if source_parts.username or source_parts.password:
raise SourceStructureChanged(
"Source record URL must not contain credentials"
)
if not host_allowed(source_parts.hostname, self.policy.allowed_hosts):
raise SourceStructureChanged(
"Source record URL host is outside the versioned source allowlist"
)
source_url = redact_url(source_url)
if not title or not description or not enterprise:
raise SourceStructureChanged(
"Source record is missing title, description or enterprise"
)
if not re.fullmatch(r"\d{6}", region_code):
raise SourceStructureChanged(
"Source record has no valid six-digit region_code"
)
description = CONTACT_RE.sub("[CONTACT_REDACTED]", description)[:30_000]
salary_min = _number(_first(source, self._aliases("salary_min")))
salary_max = _number(_first(source, self._aliases("salary_max")))
salary_currency = (
strip_markup(_first(source, self._aliases("salary_currency"))).upper()
or None
)
salary_period = _salary_period(_first(source, self._aliases("salary_period")))
if salary_min is None and salary_max is None:
salary_min, salary_max, schema_currency, schema_period = _schema_org_salary(
source
)
salary_currency = salary_currency or (
schema_currency.upper() if schema_currency else None
)
salary_period = salary_period or schema_period
if salary_min is not None or salary_max is not None:
if salary_period not in {"month", "year", "day", "hour"}:
raise SourceStructureChanged(
"Numeric salary has no supported conversion period"
)
if (
salary_min is not None
and salary_max is not None
and salary_max < salary_min
):
raise SourceStructureChanged("Source salary maximum is lower than minimum")
if salary_min is not None or salary_max is not None:
salary_currency = salary_currency or "CNY"
salary_min = _monthly_salary(salary_min, salary_period)
salary_max = _monthly_salary(salary_max, salary_period)
record: dict = {
"record_id": str(
uuid.uuid5(RECORD_NAMESPACE, f"{self.policy.code}:{source_record_id}")
),
"source_platform": self.policy.code,
"source_record_id": source_record_id[:256],
"source_url": source_url[:2048],
"collector_task_id": task_id,
"collector_version": self.policy.connector_version,
"raw_snapshot_id": item.raw_snapshot_id,
"collected_at": item.collected_at,
"first_seen_at": _datetime(source.get("first_seen_at"))
or item.collected_at,
"last_seen_at": _datetime(source.get("last_seen_at")) or item.collected_at,
"published_at": _datetime(_first(source, self._aliases("published_at"))),
"status": _status(_first(source, self._aliases("status"))),
"job_title_raw": title[:200],
"job_title_normalized": strip_markup(source.get("job_title_normalized"))[
:200
]
or None,
"job_code": strip_markup(source.get("job_code"))[:32] or None,
"job_description": description,
"enterprise_name": enterprise[:300],
"enterprise_id": strip_markup(source.get("enterprise_id"))[:128] or None,
"enterprise_credit_code": strip_markup(source.get("enterprise_credit_code"))
or None,
"enterprise_industry_code": strip_markup(
source.get("enterprise_industry_code")
)[:32]
or None,
"enterprise_scale": source.get("enterprise_scale")
if source.get("enterprise_scale")
in {"micro", "small", "medium", "large", "unknown"}
else None,
"region_code": region_code,
"workplace": strip_markup(_first(source, self._aliases("workplace")))[:500]
or None,
"employment_type": _employment_type(
_first(source, self._aliases("employment_type"))
),
"recruitment_count": _integer(
_first(source, self._aliases("recruitment_count"))
),
"education_level": _education(
_first(source, self._aliases("education_level"))
),
"experience_min_years": _number(
_first(source, self._aliases("experience_min_years"))
),
"salary_min": salary_min,
"salary_max": salary_max,
"salary_currency": salary_currency,
"salary_period": salary_period,
"benefits": _string_list(_first(source, self._aliases("benefits")))[:100],
"skills": _string_list(_first(source, self._aliases("skills"))),
"keywords": _string_list(_first(source, self._aliases("keywords"))),
"source_payload_hash": item.source_payload_hash,
"parsing_version": f"{self.policy.code}-Parser/0.1.0",
"normalized_at": item.collected_at,
"data_version": data_version,
}
record = {key: value for key, value in record.items() if value is not None}
record["content_hash"] = canonical_content_hash(
record,
{
"record_id",
"source_platform",
"source_record_id",
"source_url",
"collector_task_id",
"collector_version",
"raw_snapshot_id",
"collected_at",
"first_seen_at",
"last_seen_at",
"source_payload_hash",
"parsing_version",
"normalized_at",
"data_version",
},
)
return record
@@ -0,0 +1,69 @@
"""Tenant-isolated school system connectors."""
from __future__ import annotations
import uuid
from ..errors import PiiRejected, SourceStructureChanged
from ..hashing import canonical_content_hash
from ..models import ParsedItem
from ..security import prohibited_school_keys
from .base import BaseConnector
SCHOOL_NAMESPACE = uuid.UUID("6a512614-2321-4b54-9141-c24a85d6c780")
class SchoolConnector(BaseConnector):
def normalize(
self,
item: ParsedItem,
*,
task_id: str,
data_version: str,
default_region_code: str | None,
) -> dict:
del task_id, default_region_code
prohibited = prohibited_school_keys(item.value)
if prohibited:
raise PiiRejected(
f"School record contains prohibited fields: {', '.join(prohibited)}"
)
source_record_id = str(item.value.get("source_record_id") or "").strip()
tenant_id = str(item.value.get("tenant_id") or "").strip()
if not source_record_id or not tenant_id:
raise SourceStructureChanged(
"School record requires tenant_id and source_record_id"
)
record = dict(item.value)
record["record_id"] = record.get("record_id") or str(
uuid.uuid5(
SCHOOL_NAMESPACE, f"{self.policy.code}:{tenant_id}:{source_record_id}"
)
)
record["data_version"] = data_version
record["created_at"] = record.get("created_at") or item.collected_at
record["updated_at"] = (
record.get("updated_at")
or record.get("source_updated_at")
or item.collected_at
)
record["content_hash"] = canonical_content_hash(
record,
{
"record_id",
"content_hash",
"data_version",
"created_at",
"updated_at",
"source_updated_at",
},
)
return record
class SchoolEmploymentConnector(SchoolConnector):
pass
class SchoolInternshipConnector(SchoolConnector):
pass
@@ -0,0 +1,90 @@
"""Concrete connector types for all registered sources."""
from __future__ import annotations
from ..policies import source_policy
from .base import BaseConnector
from .external import ExternalJobConnector
from .school import SchoolEmploymentConnector, SchoolInternshipConnector
class BossConnector(ExternalJobConnector):
aliases = {
"source_record_id": ("job_number",),
"job_title_raw": ("job_name",),
"enterprise_name": ("brand_name",),
}
class ZhaopinConnector(ExternalJobConnector):
aliases = {
"source_record_id": ("position_number",),
"job_title_raw": ("position_name",),
"enterprise_name": ("company.name",),
"region_code": ("work_city_code",),
}
class BrzpConnector(ExternalJobConnector):
aliases = {
"source_record_id": ("job_number",),
"job_title_raw": ("job_name",),
"enterprise_name": ("company.name",),
}
class NcssConnector(ExternalJobConnector):
aliases = {
"source_record_id": ("position_id",),
"job_title_raw": ("position_name",),
"enterprise_name": ("unit_name",),
}
class MohrssConnector(ExternalJobConnector):
aliases = {
"source_record_id": ("job_info_id",),
"job_title_raw": ("position_name",),
"enterprise_name": ("organization_name",),
}
class IguopinConnector(ExternalJobConnector):
aliases = {
"source_record_id": ("position_id",),
"job_title_raw": ("position_name",),
"enterprise_name": ("organization_name",),
}
class HzrcConnector(ExternalJobConnector):
aliases = {
"source_record_id": ("position_id",),
"job_title_raw": ("position_name",),
"enterprise_name": ("unit_name",),
}
CONNECTOR_TYPES: dict[str, type[BaseConnector]] = {
"BOSS": BossConnector,
"ZHAOPIN": ZhaopinConnector,
"BRZP": BrzpConnector,
"NCSS": NcssConnector,
"MOHRSS": MohrssConnector,
"IGUOPIN": IguopinConnector,
"HZRC": HzrcConnector,
"SCHOOL_EMPLOYMENT": SchoolEmploymentConnector,
"SCHOOL_INTERNSHIP": SchoolInternshipConnector,
}
def connector_for(code: str) -> BaseConnector:
normalized = code.upper()
try:
connector_type = CONNECTOR_TYPES[normalized]
except KeyError as exc:
supported = ", ".join(CONNECTOR_TYPES)
raise ValueError(
f"Unknown source {code!r}; supported sources: {supported}"
) from exc
return connector_type(source_policy(normalized))
@@ -0,0 +1,53 @@
"""Domain errors with stable machine-readable codes."""
class CrawlerError(Exception):
code = "CRAWLER_ERROR"
class ConfigurationError(CrawlerError):
code = "CONFIGURATION_ERROR"
class AuthorizationRequired(CrawlerError):
code = "AUTHORIZATION_REQUIRED"
class RobotsDenied(CrawlerError):
code = "ROBOTS_DENIED"
class HostNotAllowed(CrawlerError):
code = "HOST_NOT_ALLOWED"
class PrivateAddressBlocked(CrawlerError):
code = "PRIVATE_ADDRESS_BLOCKED"
class AuthOrChallenge(CrawlerError):
code = "AUTH_OR_CHALLENGE"
class RateLimited(CrawlerError):
code = "RATE_LIMITED"
class ResponseTooLarge(CrawlerError):
code = "RESPONSE_TOO_LARGE"
class SourceStructureChanged(CrawlerError):
code = "SOURCE_STRUCTURE_CHANGED"
class SchemaValidationFailed(CrawlerError):
code = "SCHEMA_VALIDATION_FAILED"
class PiiRejected(CrawlerError):
code = "PII_REJECTED"
class TaskOutputExists(CrawlerError):
code = "TASK_OUTPUT_EXISTS"
@@ -0,0 +1,17 @@
"""Canonical business-content hashing."""
from __future__ import annotations
import json
from collections.abc import Collection
from hashlib import sha256
def canonical_content_hash(record: dict, excluded_fields: Collection[str]) -> str:
canonical = {
key: value for key, value in record.items() if key not in excluded_fields
}
payload = json.dumps(
canonical, ensure_ascii=False, sort_keys=True, separators=(",", ":")
).encode()
return sha256(payload).hexdigest()
+229
View File
@@ -0,0 +1,229 @@
"""HTTP client with source policy, robots and redirect enforcement."""
from __future__ import annotations
import os
import re
import socket
import time
from collections.abc import Callable
from urllib.parse import urljoin, urlsplit
from urllib.robotparser import RobotFileParser
import httpx
from .errors import (
AuthOrChallenge,
ConfigurationError,
RateLimited,
ResponseTooLarge,
RobotsDenied,
SourceStructureChanged,
)
from .models import RawDocument, SourcePolicy
from .security import (
contains_challenge,
normalize_host,
redact_url,
validate_contact,
validate_public_url,
)
from .transport import PinnedHTTPTransport
class SafeHttpClient:
def __init__(
self,
policy: SourcePolicy,
*,
contact: str,
approved_hosts: tuple[str, ...] = (),
bearer_token_env: str | None = None,
transport: httpx.BaseTransport | None = None,
resolver: Callable[..., list[tuple]] | None = None,
) -> None:
self.policy = policy
self.contact = validate_contact(contact)
if not approved_hosts:
raise ConfigurationError(
"At least one explicitly approved source host is required"
)
policy_hosts = {normalize_host(host) for host in policy.allowed_hosts}
unexpected_hosts = sorted(
{normalize_host(host) for host in approved_hosts} - policy_hosts
)
if unexpected_hosts:
raise ConfigurationError(
f"Approved host is not in the versioned {policy.code} allowlist: {', '.join(unexpected_hosts)}"
)
self.allowed_hosts = tuple(dict.fromkeys(approved_hosts))
self.user_agent = (
f"EduIndustryCollector/0.1 (+mailto:{self.contact}; source={policy.code})"
)
self._resolver = resolver
self._last_request_at = 0.0
self._robots: dict[str, RobotFileParser] = {}
headers = {
"Accept": "application/json, application/ld+json, text/html;q=0.8",
"User-Agent": self.user_agent,
}
self._bearer_token: str | None = None
if bearer_token_env:
if not re.fullmatch(r"[A-Z][A-Z0-9_]{1,127}", bearer_token_env):
raise ConfigurationError(
"Bearer token environment variable name is invalid"
)
token = os.environ.get(bearer_token_env)
if not token:
raise ConfigurationError(
f"Bearer token environment variable is not set: {bearer_token_env}"
)
if len(self.allowed_hosts) != 1:
raise ConfigurationError(
"Bearer token tasks must approve exactly one source host"
)
self._bearer_token = token
self._client = httpx.Client(
headers=headers,
follow_redirects=False,
timeout=httpx.Timeout(30.0, connect=10.0),
transport=transport or PinnedHTTPTransport(resolver or socket.getaddrinfo),
trust_env=False,
)
def __enter__(self) -> "SafeHttpClient":
return self
def __exit__(self, *_args: object) -> None:
self.close()
def close(self) -> None:
self._client.close()
def _validate(self, url: str) -> str:
kwargs = {"resolver": self._resolver} if self._resolver else {}
return validate_public_url(url, self.allowed_hosts, **kwargs)
def _wait_for_rate_limit(self) -> None:
elapsed = time.monotonic() - self._last_request_at
remaining = self.policy.minimum_interval_seconds - elapsed
if remaining > 0:
time.sleep(remaining)
def _request(
self,
url: str,
*,
allow_not_found: bool = False,
authorization_host: str | None = None,
) -> tuple[httpx.Response, bytes]:
current = self._validate(url)
for _ in range(6):
self._wait_for_rate_limit()
current_host = normalize_host(urlsplit(current).hostname or "")
request_headers = {}
if self._bearer_token and authorization_host == current_host:
request_headers["Authorization"] = f"Bearer {self._bearer_token}"
try:
with self._client.stream(
"GET", current, headers=request_headers
) as response:
self._last_request_at = time.monotonic()
if response.status_code in {301, 302, 303, 307, 308}:
location = response.headers.get("location")
if not location:
raise SourceStructureChanged(
"Source returned a redirect without a location"
)
current = self._validate(urljoin(current, location))
continue
if response.status_code == 429:
raise RateLimited(
"Source rate limit was reached; task stopped without bypass"
)
chunks: list[bytes] = []
size = 0
for chunk in response.iter_bytes():
size += len(chunk)
if size > self.policy.maximum_response_bytes:
raise ResponseTooLarge(
"Source response exceeded the approved size limit"
)
chunks.append(chunk)
body = b"".join(chunks)
text = body[:100_000].decode(
response.encoding or "utf-8", errors="replace"
)
if contains_challenge(response.status_code, text):
raise AuthOrChallenge(
"Source requested authentication or access verification; task stopped"
)
if response.status_code >= 400 and not (
allow_not_found and response.status_code == 404
):
raise SourceStructureChanged(
f"Source returned HTTP {response.status_code}"
)
return response, body
except httpx.HTTPError as exc:
raise SourceStructureChanged("Source network request failed") from exc
raise SourceStructureChanged("Source exceeded the maximum redirect count")
def _robots_parser(self, url: str) -> RobotFileParser:
parts = urlsplit(url)
origin = f"{parts.scheme}://{parts.netloc}"
cached = self._robots.get(origin)
if cached:
return cached
robots_url = f"{origin}/robots.txt"
response, body = self._request(robots_url, allow_not_found=True)
parser = RobotFileParser()
parser.set_url(robots_url)
content_type = response.headers.get("content-type", "")
if response.status_code == 404:
parser.parse([])
elif "text" not in content_type and content_type:
raise RobotsDenied("robots.txt did not return a text response")
else:
parser.parse(
body.decode(response.encoding or "utf-8", errors="replace").splitlines()
)
self._robots[origin] = parser
return parser
def fetch(self, url: str) -> RawDocument:
approved_url = self._validate(url)
if self.policy.robots_required and not self._robots_parser(
approved_url
).can_fetch(self.user_agent, approved_url):
raise RobotsDenied(
f"robots.txt denies the approved URL: {redact_url(approved_url)}"
)
authorization_host = (
normalize_host(urlsplit(approved_url).hostname or "")
if self._bearer_token
else None
)
response, body = self._request(
approved_url, authorization_host=authorization_host
)
content_type = (
response.headers.get("content-type", "application/octet-stream")
.split(";", 1)[0]
.strip()
.lower()
)
if content_type not in {
"application/json",
"application/ld+json",
"application/x-ndjson",
"text/html",
"text/plain",
"text/csv",
}:
raise SourceStructureChanged(
f"Unsupported source content type: {content_type or 'missing'}"
)
return RawDocument.create(
self.policy.code, redact_url(str(response.url)), content_type, body
)
@@ -0,0 +1,81 @@
"""Shared immutable connector models."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timezone
from hashlib import sha256
from typing import Literal
SourceKind = Literal["commercial", "public", "school"]
AccessMode = Literal["authorized_feed", "public_document", "school_file"]
@dataclass(frozen=True)
class SourcePolicy:
code: str
display_name: str
kind: SourceKind
access_mode: AccessMode
connector_version: str
target_schema: str
allowed_hosts: tuple[str, ...] = ()
job_link_patterns: tuple[str, ...] = ()
authorization_required: bool = True
robots_required: bool = True
minimum_interval_seconds: float = 3.0
maximum_pages: int = 20
maximum_records: int = 10_000
maximum_response_bytes: int = 5 * 1024 * 1024
@property
def network_capable(self) -> bool:
return self.kind != "school"
@dataclass(frozen=True)
class RawDocument:
source_code: str
source_url: str
content_type: str
body: bytes
collected_at: str
payload_hash: str
snapshot_id: str
@classmethod
def create(
cls, source_code: str, source_url: str, content_type: str, body: bytes
) -> "RawDocument":
collected_at = (
datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds")
)
payload_hash = sha256(body).hexdigest()
stamp = collected_at[:10].replace("-", "")
return cls(
source_code=source_code,
source_url=source_url,
content_type=content_type,
body=body,
collected_at=collected_at,
payload_hash=payload_hash,
snapshot_id=f"RAW-{source_code}-{stamp}-{payload_hash[:12].upper()}",
)
@dataclass(frozen=True)
class ParsedItem:
value: dict
raw_snapshot_id: str
source_payload_hash: str
collected_at: str
source_url: str
@dataclass(frozen=True)
class QuarantineItem:
source_code: str
raw_snapshot_id: str
source_record_id: str | None
error_code: str
error: str
@@ -0,0 +1,184 @@
"""Safe structured-feed and public-document parsing helpers."""
from __future__ import annotations
import csv
import io
import json
import re
from collections.abc import Iterable
from html import unescape
from urllib.parse import urljoin
from bs4 import BeautifulSoup
from .errors import SourceStructureChanged
from .models import RawDocument, SourcePolicy
COLLECTION_KEYS = ("records", "items", "jobs", "results", "rows", "list")
def _json_collections(value: object) -> Iterable[dict]:
if isinstance(value, list):
for item in value:
if isinstance(item, dict):
yield item
return
if not isinstance(value, dict):
return
item_type = value.get("@type")
if item_type == "JobPosting" or (
isinstance(item_type, list) and "JobPosting" in item_type
):
yield value
return
for key in COLLECTION_KEYS:
nested = value.get(key)
if isinstance(nested, list):
yield from _json_collections(nested)
return
data = value.get("data")
if isinstance(data, (dict, list)):
yielded = list(_json_collections(data))
if yielded:
yield from yielded
return
if value:
yield value
def parse_json_body(body: bytes) -> list[dict]:
try:
text = body.decode("utf-8-sig")
except UnicodeDecodeError as exc:
raise SourceStructureChanged("Structured source is not valid UTF-8") from exc
try:
value = json.loads(text)
except json.JSONDecodeError:
records = []
for line_number, line in enumerate(text.splitlines(), 1):
if not line.strip():
continue
try:
item = json.loads(line)
except json.JSONDecodeError as exc:
raise SourceStructureChanged(
f"Invalid NDJSON at line {line_number}"
) from exc
if not isinstance(item, dict):
raise SourceStructureChanged(
f"NDJSON line {line_number} is not an object"
)
records.append(item)
if not records:
raise SourceStructureChanged("Structured source returned no JSON records")
return records
records = list(_json_collections(value))
if not records:
raise SourceStructureChanged("Structured source returned no records")
return records
def parse_csv_body(body: bytes) -> list[dict]:
try:
text = body.decode("utf-8-sig")
except UnicodeDecodeError as exc:
raise SourceStructureChanged("CSV source is not valid UTF-8") from exc
reader = csv.DictReader(io.StringIO(text))
if not reader.fieldnames:
raise SourceStructureChanged("CSV source has no header")
records = [dict(row) for row in reader]
if not records:
raise SourceStructureChanged("CSV source returned no records")
return records
def _walk_json_ld(value: object) -> Iterable[dict]:
if isinstance(value, list):
for item in value:
yield from _walk_json_ld(item)
elif isinstance(value, dict):
item_type = value.get("@type")
if item_type == "JobPosting" or (
isinstance(item_type, list) and "JobPosting" in item_type
):
yield value
graph = value.get("@graph")
if graph is not None:
yield from _walk_json_ld(graph)
def parse_html_jobs(body: bytes) -> list[dict]:
soup = BeautifulSoup(body, "html.parser")
jobs: list[dict] = []
for script in soup.select('script[type="application/ld+json"]'):
try:
value = json.loads(script.get_text(strip=True))
except json.JSONDecodeError:
continue
jobs.extend(_walk_json_ld(value))
if jobs:
return jobs
for node in soup.select("[data-job-id][data-job-title]"):
description_node = node.select_one("[data-job-description]")
enterprise_node = node.select_one("[data-enterprise-name]")
jobs.append(
{
"source_record_id": node.get("data-job-id"),
"job_title_raw": node.get("data-job-title"),
"job_description": description_node.get_text(" ", strip=True)
if description_node
else "",
"enterprise_name": enterprise_node.get_text(" ", strip=True)
if enterprise_node
else "",
"source_url": node.get("data-job-url"),
"region_code": node.get("data-region-code"),
}
)
if not jobs:
raise SourceStructureChanged(
"HTML source has no approved JobPosting or versioned job elements"
)
return jobs
def discover_job_links(document: RawDocument, policy: SourcePolicy) -> list[str]:
if document.content_type != "text/html" or not policy.job_link_patterns:
return []
soup = BeautifulSoup(document.body, "html.parser")
patterns = [
re.compile(pattern, re.IGNORECASE) for pattern in policy.job_link_patterns
]
links: list[str] = []
for anchor in soup.select("a[href]"):
target = urljoin(document.source_url, str(anchor.get("href")))
if any(pattern.search(target) for pattern in patterns):
links.append(target)
return list(dict.fromkeys(links))
def parse_document(document: RawDocument, *, allow_html: bool) -> list[dict]:
if document.content_type == "text/csv":
return parse_csv_body(document.body)
if document.content_type in {
"application/json",
"application/ld+json",
"application/x-ndjson",
"text/plain",
}:
return parse_json_body(document.body)
if document.content_type == "text/html" and allow_html:
return parse_html_jobs(document.body)
raise SourceStructureChanged(
f"Source mode does not allow content type {document.content_type}"
)
def strip_markup(value: object) -> str:
if value is None:
return ""
text = unescape(str(value))
if "<" in text or ">" in text:
text = BeautifulSoup(text, "html.parser").get_text(" ", strip=True)
return re.sub(r"\s+", " ", text).strip()
@@ -0,0 +1,117 @@
"""Versioned source policy catalog."""
from __future__ import annotations
from .models import SourcePolicy
SOURCE_POLICIES: dict[str, SourcePolicy] = {
"BOSS": SourcePolicy(
code="BOSS",
display_name="BOSS直聘",
kind="commercial",
access_mode="authorized_feed",
connector_version="BOSS-Collector/0.1.0",
target_schema="external-job-source.schema.json",
allowed_hosts=("www.bosszhipin.com", "www.zhipin.com"),
robots_required=False,
minimum_interval_seconds=6.0,
),
"ZHAOPIN": SourcePolicy(
code="ZHAOPIN",
display_name="智联招聘",
kind="commercial",
access_mode="authorized_feed",
connector_version="ZHAOPIN-Collector/0.1.0",
target_schema="external-job-source.schema.json",
allowed_hosts=("www.zhaopin.com",),
robots_required=False,
minimum_interval_seconds=6.0,
),
"BRZP": SourcePolicy(
code="BRZP",
display_name="全国中小企业招聘平台",
kind="public",
access_mode="public_document",
connector_version="BRZP-Collector/0.1.0",
target_schema="external-job-source.schema.json",
allowed_hosts=("brzp.chinasme.cn", "zrc-sme.chinasme.cn"),
job_link_patterns=(r"/job/", r"/brzp/job/"),
minimum_interval_seconds=3.0,
),
"NCSS": SourcePolicy(
code="NCSS",
display_name="国家大学生就业服务平台",
kind="public",
access_mode="public_document",
connector_version="NCSS-Collector/0.1.0",
target_schema="external-job-source.schema.json",
allowed_hosts=("www.ncss.cn", "job.ncss.cn"),
job_link_patterns=(r"/jobs?/", r"job(?:detail|info)", r"/student/.+job"),
minimum_interval_seconds=4.0,
),
"MOHRSS": SourcePolicy(
code="MOHRSS",
display_name="中国公共招聘网",
kind="public",
access_mode="public_document",
connector_version="MOHRSS-Collector/0.1.0",
target_schema="external-job-source.schema.json",
allowed_hosts=("job.mohrss.gov.cn", "www.job.mohrss.gov.cn"),
job_link_patterns=(r"/cjobs/.+(?:job|institution)", r"jobinfo"),
minimum_interval_seconds=4.0,
),
"IGUOPIN": SourcePolicy(
code="IGUOPIN",
display_name="国聘",
kind="public",
access_mode="authorized_feed",
connector_version="IGUOPIN-Collector/0.1.0",
target_schema="external-job-source.schema.json",
allowed_hosts=("www.iguopin.com", "job.iguopin.com", "xiaoyuan.iguopin.com"),
robots_required=False,
minimum_interval_seconds=5.0,
),
"HZRC": SourcePolicy(
code="HZRC",
display_name="杭州人才网",
kind="public",
access_mode="public_document",
connector_version="HZRC-Collector/0.1.0",
target_schema="external-job-source.schema.json",
allowed_hosts=("www.hzrc.com", "hzrc.com", "yph.hzrc.com"),
job_link_patterns=(r"(?:job|position|posiiton)",),
minimum_interval_seconds=8.0,
maximum_pages=10,
),
"SCHOOL_EMPLOYMENT": SourcePolicy(
code="SCHOOL_EMPLOYMENT",
display_name="校内就业管理系统",
kind="school",
access_mode="school_file",
connector_version="School-Employment-Connector/0.1.0",
target_schema="school-employment.schema.json",
robots_required=False,
minimum_interval_seconds=0,
),
"SCHOOL_INTERNSHIP": SourcePolicy(
code="SCHOOL_INTERNSHIP",
display_name="校内实习管理系统",
kind="school",
access_mode="school_file",
connector_version="School-Internship-Connector/0.1.0",
target_schema="school-internship.schema.json",
robots_required=False,
minimum_interval_seconds=0,
),
}
def source_policy(code: str) -> SourcePolicy:
try:
return SOURCE_POLICIES[code.upper()]
except KeyError as exc:
supported = ", ".join(SOURCE_POLICIES)
raise ValueError(
f"Unknown source {code!r}; supported sources: {supported}"
) from exc
@@ -0,0 +1,328 @@
"""End-to-end connector task runner."""
from __future__ import annotations
import re
from contextlib import contextmanager
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from urllib.parse import urlsplit
from .connectors import connector_for
from .errors import AuthorizationRequired, ConfigurationError, CrawlerError, PiiRejected
from .http import SafeHttpClient
from .models import ParsedItem, QuarantineItem, RawDocument
from .parsing import discover_job_links, parse_document
from .schema import validate_record
from .security import (
prohibited_school_keys,
prohibited_school_values,
safe_record_reference,
)
from .storage import TaskOutput
TASK_ID_PATTERN = re.compile(r"^JOB-[A-Za-z0-9-]{8,64}$")
AUTHORIZATION_REFERENCE_PATTERN = re.compile(
r"^(?:AUTH|CONTRACT|LICENSE|APPROVAL|DPA)-[A-Za-z0-9._:/-]{3,118}$"
)
@contextmanager
def closing_iterator(iterator):
try:
yield iterator
finally:
close = getattr(iterator, "close", None)
if close:
close()
@dataclass(frozen=True)
class RunConfig:
source_code: str
input_location: str
task_id: str
data_version: str
output_root: Path
authorization_reference: str | None = None
allow_network: bool = False
approved_hosts: tuple[str, ...] = ()
contact: str | None = None
bearer_token_env: str | None = None
max_pages: int | None = None
max_records: int | None = None
default_region_code: str | None = None
tenant_id: str | None = None
class CrawlerRunner:
def __init__(self, config: RunConfig) -> None:
self.config = config
self.connector = connector_for(config.source_code)
self.policy = self.connector.policy
def _validate_config(self) -> None:
if not TASK_ID_PATTERN.fullmatch(self.config.task_id):
raise ConfigurationError("task_id must match JOB-[A-Za-z0-9-]{8,64}")
if self.policy.authorization_required:
reference = (self.config.authorization_reference or "").strip()
if not reference:
raise AuthorizationRequired(
f"{self.policy.code} requires a non-empty authorization reference"
)
if not AUTHORIZATION_REFERENCE_PATTERN.fullmatch(reference):
raise ConfigurationError(
"authorization_reference must be a non-secret audit reference"
)
if self.config.allow_network:
if not self.policy.network_capable:
raise ConfigurationError(
"School connectors do not allow direct network collection"
)
if not self.config.contact:
raise ConfigurationError(
"Network collection requires a technical contact email"
)
if not self.config.approved_hosts:
raise ConfigurationError(
"Network collection requires at least one explicitly approved host"
)
elif urlsplit(self.config.input_location).scheme in {"http", "https"}:
raise ConfigurationError("Network input requires --allow-network")
if self.config.default_region_code and not re.fullmatch(
r"\d{6}", self.config.default_region_code
):
raise ConfigurationError(
"default_region_code must be a six-digit GB/T 2260 code"
)
if self.policy.kind == "school":
if not self.config.tenant_id or not re.fullmatch(
r"TENANT-[A-Z0-9-]{4,64}", self.config.tenant_id
):
raise ConfigurationError(
"School collection requires a valid --tenant-id"
)
elif self.config.tenant_id:
raise ConfigurationError("tenant_id is only valid for school connectors")
if self.config.max_pages is not None and self.config.max_pages < 1:
raise ConfigurationError("max_pages must be at least 1")
if self.config.max_records is not None and self.config.max_records < 1:
raise ConfigurationError("max_records must be at least 1")
version_patterns = {
"external-job-source.schema.json": r"^V\d{4}\.\d{2}\.\d{2}(?:\.\d+)?$",
"school-employment.schema.json": r"^SCH-EMP-\d{8}(?:-\d{2})?$",
"school-internship.schema.json": r"^SCH-INT-\d{8}(?:-\d{2})?$",
}
if not re.fullmatch(
version_patterns[self.policy.target_schema], self.config.data_version
):
raise ConfigurationError(
f"data_version is invalid for {self.policy.target_schema}"
)
def _local_document(self) -> RawDocument:
path = Path(self.config.input_location).expanduser().resolve()
if not path.is_file():
raise ConfigurationError(f"Input file does not exist: {path.name}")
content_types = {
".json": "application/json",
".jsonl": "application/x-ndjson",
".ndjson": "application/x-ndjson",
".csv": "text/csv",
".html": "text/html",
".htm": "text/html",
".txt": "text/plain",
}
content_type = content_types.get(path.suffix.lower())
if not content_type:
raise ConfigurationError(
"Input file must be JSON, NDJSON, CSV, HTML or text"
)
with path.open("rb") as stream:
body = stream.read(self.policy.maximum_response_bytes + 1)
if len(body) > self.policy.maximum_response_bytes:
raise ConfigurationError("Local input exceeds the approved size limit")
return RawDocument.create(
self.policy.code, f"file:///{path.name}", content_type, body
)
def _network_documents(self):
configured_pages = (
self.policy.maximum_pages
if self.config.max_pages is None
else self.config.max_pages
)
maximum_pages = min(configured_pages, self.policy.maximum_pages)
if maximum_pages < 1:
raise ConfigurationError("max_pages must be at least 1")
client = SafeHttpClient(
self.policy,
contact=self.config.contact or "",
approved_hosts=self.config.approved_hosts,
bearer_token_env=self.config.bearer_token_env,
)
try:
entry = client.fetch(self.config.input_location)
yield entry
if (
self.policy.access_mode == "public_document"
and entry.content_type == "text/html"
):
links = discover_job_links(entry, self.policy)
for link in links[: maximum_pages - 1]:
yield client.fetch(link)
finally:
client.close()
def _documents(self):
if self.config.allow_network:
return self._network_documents()
return iter((self._local_document(),))
def run(self) -> Path:
self._validate_config()
configured_records = (
self.policy.maximum_records
if self.config.max_records is None
else self.config.max_records
)
maximum_records = min(configured_records, self.policy.maximum_records)
if maximum_records < 1:
raise ConfigurationError("max_records must be at least 1")
started_at = (
datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds")
)
documents = self._documents()
document_count = 0
standard_count = 0
quarantine_count = 0
parsed_count = 0
with (
closing_iterator(documents),
TaskOutput(
self.config.output_root,
self.policy.code,
self.config.task_id,
self.config.tenant_id,
) as output,
):
for document in documents:
document_count += 1
source_records: list[dict] | None = None
if self.policy.kind == "school":
source_records = parse_document(document, allow_html=False)
mismatched_tenants = sorted(
{
str(source_record.get("tenant_id") or "")
for source_record in source_records
if source_record.get("tenant_id") != self.config.tenant_id
}
)
if mismatched_tenants:
raise ConfigurationError(
"School input contains a record outside the configured tenant"
)
rejected_paths = sorted(
{
key
for source_record in source_records
for key in (
*prohibited_school_keys(source_record),
*prohibited_school_values(source_record),
)
}
)
if rejected_paths:
raise PiiRejected(
f"School input contains prohibited fields: {', '.join(rejected_paths[:10])}"
)
output.write_raw(
document,
task_id=self.config.task_id,
connector_version=self.policy.connector_version,
)
if source_records is None:
try:
source_records = parse_document(
document,
allow_html=self.policy.access_mode == "public_document",
)
except CrawlerError as exc:
output.write_quarantine(
QuarantineItem(
source_code=self.policy.code,
raw_snapshot_id=document.snapshot_id,
source_record_id=None,
error_code=exc.code,
error=str(exc),
)
)
quarantine_count += 1
continue
for source_record in source_records:
if parsed_count >= maximum_records:
break
parsed_count += 1
item = ParsedItem(
value=source_record,
raw_snapshot_id=document.snapshot_id,
source_payload_hash=document.payload_hash,
collected_at=document.collected_at,
source_url=document.source_url,
)
try:
normalized = self.connector.normalize(
item,
task_id=self.config.task_id,
data_version=self.config.data_version,
default_region_code=self.config.default_region_code,
)
validate_record(self.policy.target_schema, normalized)
except CrawlerError as exc:
output.write_quarantine(
QuarantineItem(
source_code=self.policy.code,
raw_snapshot_id=document.snapshot_id,
source_record_id=safe_record_reference(
source_record.get("source_record_id")
or source_record.get("id")
or source_record.get("identifier")
or ""
),
error_code=exc.code,
error=str(exc),
)
)
quarantine_count += 1
continue
output.write_standard(normalized)
standard_count += 1
if parsed_count >= maximum_records:
break
finished_at = (
datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds")
)
summary = {
"task_id": self.config.task_id,
"source": self.connector.describe(),
"authorization_reference": self.config.authorization_reference,
"network_used": self.config.allow_network,
"input": (
f"https://{urlsplit(self.config.input_location).hostname}/[APPROVED]"
if self.config.allow_network
else Path(self.config.input_location).name
),
"data_version": self.config.data_version,
"started_at": started_at,
"finished_at": finished_at,
"tenant_id": self.config.tenant_id,
"raw_documents": document_count,
"parsed_records": parsed_count,
"standard_records": standard_count,
"quarantined_records": quarantine_count,
"status": "COMPLETED"
if quarantine_count == 0
else "COMPLETED_WITH_QUARANTINE",
}
return output.commit(summary)
@@ -0,0 +1,35 @@
"""JSON Schema loading and validation."""
from __future__ import annotations
import json
from functools import lru_cache
from importlib.resources import files
from itertools import islice
from jsonschema import Draft202012Validator, FormatChecker
from .errors import SchemaValidationFailed
@lru_cache(maxsize=8)
def validator_for(schema_name: str) -> Draft202012Validator:
if "/" in schema_name or "\\" in schema_name or not schema_name.endswith(".json"):
raise SchemaValidationFailed(f"Target schema is unavailable: {schema_name}")
resource = files("edu_crawler.schemas").joinpath(schema_name)
if not resource.is_file():
raise SchemaValidationFailed(f"Target schema is unavailable: {schema_name}")
schema = json.loads(resource.read_text(encoding="utf-8"))
Draft202012Validator.check_schema(schema)
return Draft202012Validator(schema, format_checker=FormatChecker())
def validate_record(schema_name: str, record: dict) -> None:
errors = list(islice(validator_for(schema_name).iter_errors(record), 5))
if not errors:
return
summaries = []
for error in errors:
path = ".".join(str(part) for part in error.absolute_path) or "$"
summaries.append(f"{path}: {error.validator} validation failed")
raise SchemaValidationFailed("; ".join(summaries))
@@ -0,0 +1 @@
"""Packaged JSON Schema resources used by crawler wheels."""
@@ -0,0 +1,266 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://schemas.edu-industry.cn/job-signal/3.0.0/schema.json",
"title": "JOB-SIGNAL V3.0 外部招聘岗位信号",
"description": "商业招聘平台、公共就业平台与招聘活动平台统一岗位信号契约。",
"type": "object",
"additionalProperties": false,
"required": [
"record_id",
"source_platform",
"source_record_id",
"source_url",
"collector_task_id",
"collector_version",
"raw_snapshot_id",
"collected_at",
"first_seen_at",
"last_seen_at",
"status",
"job_title_raw",
"job_description",
"enterprise_name",
"region_code",
"employment_type",
"source_payload_hash",
"content_hash",
"data_version"
],
"properties": {
"record_id": {
"type": "string",
"format": "uuid",
"description": "平台生成的全局唯一记录标识。"
},
"source_platform": {
"type": "string",
"pattern": "^[A-Z][A-Z0-9_]{1,31}$",
"description": "来源平台代码,例如 BOSS、ZHAOPIN、BRZP、NCSS、MOHRSS。"
},
"source_record_id": {
"type": "string",
"minLength": 1,
"maxLength": 256,
"description": "来源平台内的记录标识,与 source_platform 组成幂等键。"
},
"source_url": {
"type": "string",
"format": "uri",
"maxLength": 2048,
"description": "可回溯的来源页面或接口定位。"
},
"collector_task_id": {
"type": "string",
"pattern": "^JOB-[A-Za-z0-9-]{8,64}$",
"description": "生产采集任务追踪编号。"
},
"collector_version": {
"type": "string",
"minLength": 1,
"maxLength": 128,
"description": "采集器名称、语义版本与代码提交摘要。"
},
"raw_snapshot_id": {
"type": "string",
"pattern": "^RAW-[A-Za-z0-9-]{8,96}$",
"description": "原始响应或页面快照编号。"
},
"collected_at": {
"type": "string",
"format": "date-time",
"description": "平台实际采集时间,必须包含时区。"
},
"first_seen_at": {
"type": "string",
"format": "date-time",
"description": "平台首次发现该岗位的时间。"
},
"last_seen_at": {
"type": "string",
"format": "date-time",
"description": "平台最近一次确认该岗位仍存在的时间。"
},
"published_at": {
"type": ["string", "null"],
"format": "date-time",
"description": "来源平台显示的岗位发布时间。"
},
"status": {
"type": "string",
"enum": ["active", "inactive", "expired", "unknown"],
"description": "岗位在当前数据版本中的状态。"
},
"job_title_raw": {
"type": "string",
"minLength": 1,
"maxLength": 200,
"description": "来源岗位名称原文。"
},
"job_title_normalized": {
"type": ["string", "null"],
"maxLength": 200,
"description": "映射到统一岗位词典后的标准名称。"
},
"job_code": {
"type": ["string", "null"],
"pattern": "^[A-Z0-9.-]{2,32}$",
"description": "统一岗位分类代码。"
},
"job_description": {
"type": "string",
"minLength": 1,
"maxLength": 30000,
"description": "去除展示标记与联系方式后的岗位职责和要求文本。"
},
"enterprise_name": {
"type": "string",
"minLength": 1,
"maxLength": 300,
"description": "来源企业名称原文。"
},
"enterprise_id": {
"type": ["string", "null"],
"maxLength": 128,
"description": "平台企业归一标识。"
},
"enterprise_credit_code": {
"type": ["string", "null"],
"pattern": "^[0-9A-Z]{18}$",
"description": "可公开确认时使用的统一社会信用代码。"
},
"enterprise_industry_code": {
"type": ["string", "null"],
"maxLength": 32,
"description": "企业所属行业代码。"
},
"enterprise_scale": {
"type": ["string", "null"],
"enum": ["micro", "small", "medium", "large", "unknown", null],
"description": "统一企业规模分组。"
},
"region_code": {
"type": "string",
"pattern": "^[0-9]{6}$",
"description": "GB/T 2260 六位行政区划代码。"
},
"workplace": {
"type": ["string", "null"],
"maxLength": 500,
"description": "来源工作地点文本。"
},
"employment_type": {
"type": "string",
"enum": ["full_time", "internship", "part_time", "contract", "campus", "other"],
"description": "统一用工或招聘类型。"
},
"recruitment_count": {
"type": ["integer", "null"],
"minimum": 0,
"maximum": 1000000,
"description": "明确披露时记录计划招聘人数。"
},
"education_level": {
"type": ["string", "null"],
"enum": ["none", "secondary", "vocational", "associate", "bachelor", "master", "doctor", "unknown", null],
"description": "最低学历要求。"
},
"experience_min_years": {
"type": ["number", "null"],
"minimum": 0,
"maximum": 60,
"description": "最低工作经验年限。"
},
"salary_min": {
"type": ["number", "null"],
"minimum": 0,
"maximum": 10000000,
"description": "统一折算后的税前月薪下限,单位为 salary_currency。"
},
"salary_max": {
"type": ["number", "null"],
"minimum": 0,
"maximum": 10000000,
"description": "统一折算后的税前月薪上限。"
},
"salary_currency": {
"type": ["string", "null"],
"pattern": "^[A-Z]{3}$",
"description": "ISO 4217 币种代码,境内岗位通常为 CNY。"
},
"salary_period": {
"type": ["string", "null"],
"enum": ["month", "year", "day", "hour", "negotiable", "unknown", null],
"description": "来源薪资周期,统一月薪字段与原始周期同时保留。"
},
"benefits": {
"type": "array",
"maxItems": 100,
"items": {
"type": "string",
"maxLength": 100
},
"uniqueItems": true,
"description": "福利标签。"
},
"skills": {
"type": "array",
"maxItems": 200,
"items": {
"type": "string",
"maxLength": 100
},
"uniqueItems": true,
"description": "基于技能词典提取并归一的技能标签。"
},
"keywords": {
"type": "array",
"maxItems": 200,
"items": {
"type": "string",
"maxLength": 100
},
"uniqueItems": true,
"description": "用于检索的业务关键词。"
},
"source_payload_hash": {
"type": "string",
"pattern": "^[a-f0-9]{64}$",
"description": "原始响应载荷 SHA-256。"
},
"content_hash": {
"type": "string",
"pattern": "^[a-f0-9]{64}$",
"description": "规范化业务内容 SHA-256,用于内容去重。"
},
"parsing_version": {
"type": ["string", "null"],
"maxLength": 128,
"description": "解析规则版本。"
},
"normalized_at": {
"type": ["string", "null"],
"format": "date-time",
"description": "岗位和技能归一完成时间。"
},
"data_version": {
"type": "string",
"pattern": "^V[0-9]{4}\\.[0-9]{2}\\.[0-9]{2}(?:\\.[0-9]+)?$",
"description": "进入分析层的数据版本。"
}
},
"allOf": [
{
"if": {
"required": ["salary_min", "salary_max"]
},
"then": {
"properties": {
"salary_max": {
"type": "number",
"minimum": 0
}
}
}
}
]
}
@@ -0,0 +1,228 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://schemas.edu-industry.cn/school-employment/2.2.0/schema.json",
"title": "SCHOOL-EMPLOYMENT V2.2 校内就业事实",
"description": "职业院校毕业生就业事实统一契约,采用租户隔离和身份最小化设计。",
"type": "object",
"additionalProperties": false,
"required": [
"tenant_id",
"record_id",
"student_token",
"cohort",
"college_code",
"program_code",
"employment_status",
"destination_type",
"confirmed_at",
"confirmation_role",
"source_system",
"source_record_id",
"created_at",
"updated_at",
"data_version",
"content_hash"
],
"properties": {
"tenant_id": {
"type": "string",
"pattern": "^TENANT-[A-Z0-9-]{4,64}$",
"description": "学校租户标识,所有存储、任务和查询必须同时校验。"
},
"record_id": {
"type": "string",
"format": "uuid",
"description": "平台全局唯一记录标识。"
},
"student_token": {
"type": "string",
"pattern": "^STU_[A-Za-z0-9_-]{32,128}$",
"description": "学校侧生成的学生不可逆令牌,禁止传输姓名、身份证和明文学号。"
},
"cohort": {
"type": "integer",
"minimum": 2000,
"maximum": 2100,
"description": "毕业届次。"
},
"college_code": {
"type": "string",
"minLength": 1,
"maxLength": 64,
"description": "学校内部学院代码。"
},
"program_code": {
"type": "string",
"pattern": "^[0-9A-Z]{4,12}$",
"description": "教育部职业教育专业目录代码。"
},
"class_code": {
"type": ["string", "null"],
"maxLength": 64,
"description": "学校内部教学班代码。"
},
"employment_status": {
"type": "string",
"enum": [
"employed",
"self_employed",
"further_study",
"military",
"flexible",
"unemployed",
"pending_confirmation"
],
"description": "统一就业状态。"
},
"destination_type": {
"type": "string",
"enum": [
"enterprise",
"public_institution",
"government",
"further_study",
"entrepreneurship",
"military",
"other"
],
"description": "毕业去向类型。"
},
"job_title": {
"type": ["string", "null"],
"maxLength": 200,
"description": "就业岗位业务原文。"
},
"job_code": {
"type": ["string", "null"],
"maxLength": 32,
"description": "映射到统一岗位词典后的岗位代码。"
},
"enterprise_token": {
"type": ["string", "null"],
"pattern": "^ENT_[A-Za-z0-9_-]{24,128}$",
"description": "企业统一标识令牌。"
},
"enterprise_name": {
"type": ["string", "null"],
"maxLength": 300,
"description": "用于业务核验的企业名称;导出时按数据范围策略处理。"
},
"enterprise_industry_code": {
"type": ["string", "null"],
"maxLength": 32,
"description": "企业行业代码。"
},
"region_code": {
"type": ["string", "null"],
"pattern": "^[0-9]{6}$",
"description": "就业地区 GB/T 2260 六位代码。"
},
"salary_monthly": {
"type": ["number", "null"],
"minimum": 0,
"maximum": 10000000,
"description": "确认后的税前月薪,单位元。"
},
"salary_confirmed": {
"type": ["boolean", "null"],
"description": "薪资是否经过业务确认。"
},
"job_relevance": {
"type": ["string", "null"],
"enum": ["high", "medium", "low", "unconfirmed", null],
"description": "就业岗位与专业培养方向相关度。"
},
"employment_start_date": {
"type": ["string", "null"],
"format": "date",
"description": "就业开始日期。"
},
"contract_signed": {
"type": ["boolean", "null"],
"description": "是否签署就业协议或劳动合同。"
},
"evidence_type": {
"type": ["string", "null"],
"enum": ["contract", "offer", "registration", "enterprise_confirmation", "student_confirmation", "other", null],
"description": "就业确认材料类型,不在平台保存原始敏感附件。"
},
"evidence_reference": {
"type": ["string", "null"],
"maxLength": 256,
"description": "学校系统内的材料引用编号。"
},
"confirmed_at": {
"type": "string",
"format": "date-time",
"description": "业务确认时间。"
},
"confirmation_role": {
"type": "string",
"enum": ["employment_office", "college", "student", "enterprise", "system"],
"description": "执行确认的业务角色。"
},
"source_system": {
"type": "string",
"maxLength": 128,
"description": "学校来源系统代码。"
},
"source_record_id": {
"type": "string",
"minLength": 1,
"maxLength": 256,
"description": "校内来源系统记录标识。"
},
"source_updated_at": {
"type": ["string", "null"],
"format": "date-time",
"description": "校内来源系统业务更新时间,建议作为增量水位。"
},
"created_at": {
"type": "string",
"format": "date-time",
"description": "平台记录创建时间。"
},
"updated_at": {
"type": "string",
"format": "date-time",
"description": "平台记录更新时间。"
},
"data_version": {
"type": "string",
"pattern": "^SCH-EMP-[0-9]{8}(?:-[0-9]{2})?$",
"description": "校内就业数据版本。"
},
"is_deleted": {
"type": "boolean",
"default": false,
"description": "版本化软删除标识。"
},
"content_hash": {
"type": "string",
"pattern": "^[a-f0-9]{64}$",
"description": "去除非业务差异后的记录内容 SHA-256。"
}
},
"allOf": [
{
"if": {
"properties": {
"destination_type": {
"const": "enterprise"
}
},
"required": ["destination_type"]
},
"then": {
"anyOf": [
{
"required": ["enterprise_token"]
},
{
"required": ["enterprise_name"]
}
]
}
}
]
}
@@ -0,0 +1,222 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://schemas.edu-industry.cn/school-internship/2.1.0/schema.json",
"title": "SCHOOL-INTERNSHIP V2.1 校内实习事实",
"description": "职业院校学生实习事实统一契约,采用租户隔离、身份最小化和证据引用设计。",
"type": "object",
"additionalProperties": false,
"required": [
"tenant_id",
"record_id",
"internship_id",
"student_token",
"program_code",
"job_title",
"start_date",
"status",
"source_system",
"source_record_id",
"created_at",
"updated_at",
"data_version",
"content_hash"
],
"properties": {
"tenant_id": {
"type": "string",
"pattern": "^TENANT-[A-Z0-9-]{4,64}$",
"description": "学校租户标识,所有存储、任务和查询必须同时校验。"
},
"record_id": {
"type": "string",
"format": "uuid",
"description": "平台全局唯一记录标识。"
},
"internship_id": {
"type": "string",
"minLength": 1,
"maxLength": 128,
"description": "学校租户内的实习业务编号。"
},
"student_token": {
"type": "string",
"pattern": "^STU_[A-Za-z0-9_-]{32,128}$",
"description": "学校侧生成的学生不可逆令牌,禁止传输姓名、身份证和明文学号。"
},
"cohort": {
"type": ["integer", "null"],
"minimum": 2000,
"maximum": 2100,
"description": "学生届次。"
},
"college_code": {
"type": ["string", "null"],
"maxLength": 64,
"description": "学校内部学院代码。"
},
"program_code": {
"type": "string",
"pattern": "^[0-9A-Z]{4,12}$",
"description": "教育部职业教育专业目录代码。"
},
"enterprise_token": {
"type": ["string", "null"],
"pattern": "^ENT_[A-Za-z0-9_-]{24,128}$",
"description": "学校侧或平台企业主数据生成的企业令牌。"
},
"enterprise_name": {
"type": ["string", "null"],
"maxLength": 300,
"description": "用于业务核验的企业名称;导出时按数据范围策略处理。"
},
"enterprise_industry_code": {
"type": ["string", "null"],
"maxLength": 32,
"description": "企业行业代码。"
},
"region_code": {
"type": ["string", "null"],
"pattern": "^[0-9]{6}$",
"description": "实习地区 GB/T 2260 六位代码。"
},
"workplace": {
"type": ["string", "null"],
"maxLength": 500,
"description": "实习地点业务文本。"
},
"job_title": {
"type": "string",
"minLength": 1,
"maxLength": 200,
"description": "实习岗位名称。"
},
"job_code": {
"type": ["string", "null"],
"maxLength": 32,
"description": "映射到统一岗位词典后的岗位代码。"
},
"job_tasks": {
"type": "array",
"maxItems": 100,
"uniqueItems": true,
"items": {
"type": "string",
"maxLength": 500
},
"description": "经学校确认的实习岗位任务。"
},
"start_date": {
"type": "string",
"format": "date",
"description": "实习开始日期。"
},
"end_date": {
"type": ["string", "null"],
"format": "date",
"description": "计划或实际结束日期。"
},
"status": {
"type": "string",
"enum": ["planned", "active", "completed", "terminated", "cancelled"],
"description": "实习业务状态。"
},
"salary_monthly": {
"type": ["number", "null"],
"minimum": 0,
"maximum": 10000000,
"description": "确认后的税前月实习工资,单位元。"
},
"enterprise_mentor_evaluation": {
"type": ["string", "null"],
"maxLength": 4000,
"description": "企业导师业务评价,禁止包含学生敏感身份信息。"
},
"school_evaluation": {
"type": ["string", "null"],
"maxLength": 4000,
"description": "学校指导教师或管理部门评价。"
},
"evidence_reference": {
"type": ["string", "null"],
"maxLength": 256,
"description": "学校系统内的证据引用编号,不传输敏感附件。"
},
"source_system": {
"type": "string",
"minLength": 1,
"maxLength": 128,
"description": "学校来源系统代码。"
},
"source_record_id": {
"type": "string",
"minLength": 1,
"maxLength": 256,
"description": "校内来源系统记录标识。"
},
"source_updated_at": {
"type": ["string", "null"],
"format": "date-time",
"description": "校内来源系统业务更新时间,建议作为增量水位。"
},
"created_at": {
"type": "string",
"format": "date-time",
"description": "平台记录创建时间。"
},
"updated_at": {
"type": "string",
"format": "date-time",
"description": "平台记录更新时间。"
},
"data_version": {
"type": "string",
"pattern": "^SCH-INT-[0-9]{8}(?:-[0-9]{2})?$",
"description": "校内实习数据版本。"
},
"is_deleted": {
"type": "boolean",
"default": false,
"description": "版本化软删除标识。"
},
"content_hash": {
"type": "string",
"pattern": "^[a-f0-9]{64}$",
"description": "去除非业务差异后的记录内容 SHA-256。"
}
},
"allOf": [
{
"if": {
"properties": {
"status": {
"enum": ["active", "completed"]
}
},
"required": ["status"]
},
"then": {
"anyOf": [
{
"properties": {
"enterprise_token": {
"type": "string",
"pattern": "^ENT_[A-Za-z0-9_-]{24,128}$"
}
},
"required": ["enterprise_token"]
},
{
"properties": {
"enterprise_name": {
"type": "string",
"minLength": 1,
"maxLength": 300
}
},
"required": ["enterprise_name"]
}
]
}
}
]
}
@@ -0,0 +1,242 @@
"""Network and sensitive-data safety controls."""
from __future__ import annotations
import ipaddress
import re
import socket
from collections.abc import Callable, Iterable
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
from .errors import ConfigurationError, HostNotAllowed, PrivateAddressBlocked
Resolver = Callable[..., list[tuple]]
SENSITIVE_QUERY_KEYS = {
"access_token",
"api_key",
"apikey",
"auth",
"authorization",
"key",
"password",
"secret",
"signature",
"token",
}
CONTACT_PATTERN = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
def normalize_host(host: str) -> str:
value = host.rstrip(".").lower()
try:
return value.encode("idna").decode("ascii")
except UnicodeError as exc:
raise HostNotAllowed("Host name is not valid IDNA") from exc
def redact_url(url: str) -> str:
parts = urlsplit(url)
query = []
for key, value in parse_qsl(parts.query, keep_blank_values=True):
query.append(
(key, "[REDACTED]" if key.lower() in SENSITIVE_QUERY_KEYS else value)
)
host = parts.hostname or ""
netloc = host
if parts.port:
netloc = f"{host}:{parts.port}"
return urlunsplit((parts.scheme, netloc, parts.path, urlencode(query), ""))
def validate_contact(contact: str) -> str:
if not CONTACT_PATTERN.fullmatch(contact):
raise ConfigurationError(
"A real technical contact email is required for network collection"
)
return contact
def host_allowed(host: str, allowed_hosts: Iterable[str]) -> bool:
normalized = normalize_host(host)
return normalized in {normalize_host(item) for item in allowed_hosts}
def _assert_global_address(address: str) -> None:
try:
parsed = ipaddress.ip_address(address.split("%", 1)[0])
except ValueError as exc:
raise PrivateAddressBlocked("DNS returned an invalid address") from exc
if not parsed.is_global:
raise PrivateAddressBlocked("URL resolves to a non-public address")
def resolve_public_endpoints(
host: str,
port: int,
*,
resolver: Resolver = socket.getaddrinfo,
) -> tuple[tuple[int, int, int, tuple], ...]:
try:
addresses = resolver(host, port, type=socket.SOCK_STREAM)
except OSError as exc:
raise HostNotAllowed(f"Unable to resolve approved source host: {host}") from exc
if not addresses:
raise HostNotAllowed(f"Approved source host has no addresses: {host}")
endpoints: list[tuple[int, int, int, tuple]] = []
for family, socket_type, protocol, _canonical_name, socket_address in addresses:
_assert_global_address(socket_address[0])
endpoint = (family, socket_type, protocol, socket_address)
if endpoint not in endpoints:
endpoints.append(endpoint)
return tuple(endpoints)
def validate_public_url(
url: str,
allowed_hosts: Iterable[str],
*,
resolver: Resolver = socket.getaddrinfo,
) -> str:
parts = urlsplit(url)
if parts.scheme != "https":
raise HostNotAllowed("Only HTTPS source URLs are allowed")
if parts.username or parts.password:
raise HostNotAllowed("Credentials in source URLs are forbidden")
if not parts.hostname or not host_allowed(parts.hostname, allowed_hosts):
raise HostNotAllowed(f"Source host is not approved: {redact_url(url)}")
if parts.port not in (None, 443):
raise HostNotAllowed("Only the default HTTPS port is allowed")
host = normalize_host(parts.hostname)
try:
parsed_host = ipaddress.ip_address(host)
except ValueError:
parsed_host = None
if parsed_host is not None and not parsed_host.is_global:
raise PrivateAddressBlocked("URL contains a non-public address")
resolve_public_endpoints(host, 443, resolver=resolver)
return urlunsplit(("https", parts.netloc, parts.path or "/", parts.query, ""))
def contains_challenge(status_code: int, text: str) -> bool:
if status_code in {401, 403}:
return True
sample = text[:100_000].lower()
markers = (
"captcha",
"security verification",
"安全验证",
"访问验证",
"请输入验证码",
"请稍候",
)
return any(marker in sample for marker in markers)
PROHIBITED_SCHOOL_KEYS = {
"address",
"email",
"id_card",
"id_number",
"identity_card",
"mobile",
"phone",
"student_id",
"student_name",
"student_number",
"身份证",
"姓名",
"手机号",
"明文学号",
}
SCHOOL_STUDENT_CONTEXT_KEYS = {
"student",
"student_info",
"student_profile",
"学生",
"学生信息",
}
SCHOOL_STUDENT_IDENTITY_KEYS = {
"card_no",
"email",
"id",
"id_card",
"mobile",
"name",
"number",
"phone",
"student_no",
"学号",
"姓名",
"手机号",
"身份证",
}
SCHOOL_PII_VALUE_PATTERNS = (
re.compile(r"(?<!\d)(?:\+?86[- ]?)?1[3-9]\d{9}(?!\d)"),
re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}"),
re.compile(
r"(?<!\d)[1-9]\d{5}(?:18|19|20)\d{2}(?:0[1-9]|1[0-2])(?:0[1-9]|[12]\d|3[01])\d{3}[\dXx](?!\d)"
),
)
def prohibited_school_keys(record: dict) -> list[str]:
found: set[str] = set()
def visit(value: object, path: str) -> None:
if isinstance(value, dict):
for key, nested in value.items():
key_text = str(key)
nested_path = f"{path}.{key_text}" if path else key_text
if (
key_text.lower() in PROHIBITED_SCHOOL_KEYS
or key_text in PROHIBITED_SCHOOL_KEYS
):
found.add(nested_path)
path_parts = {part.lower() for part in path.split(".") if part}
if path_parts & SCHOOL_STUDENT_CONTEXT_KEYS and (
key_text.lower() in SCHOOL_STUDENT_IDENTITY_KEYS
or key_text in SCHOOL_STUDENT_IDENTITY_KEYS
):
found.add(nested_path)
visit(nested, nested_path)
elif isinstance(value, list):
for index, nested in enumerate(value):
visit(nested, f"{path}[{index}]")
visit(record, "")
return sorted(found)
def prohibited_school_values(record: dict) -> list[str]:
found: set[str] = set()
def visit(value: object, path: str) -> None:
if isinstance(value, dict):
for key, nested in value.items():
visit(nested, f"{path}.{key}" if path else str(key))
elif isinstance(value, list):
for index, nested in enumerate(value):
visit(nested, f"{path}[{index}]")
elif isinstance(value, str) and not path.endswith(
("_token", "source_record_id", "record_id")
):
if any(pattern.search(value) for pattern in SCHOOL_PII_VALUE_PATTERNS):
found.add(path)
visit(record, "")
return sorted(found)
def safe_record_reference(value: object) -> str | None:
if isinstance(value, dict):
value = value.get("value") or value.get("name") or value.get("@id")
text = re.sub(r"[\x00-\x1f\x7f]+", " ", str(value or "")).strip()
if not text:
return None
if any(pattern.search(text) for pattern in SCHOOL_PII_VALUE_PATTERNS):
return None
parts = urlsplit(text)
if parts.scheme in {"http", "https"}:
text = redact_url(text)
return text[:256] or None
@@ -0,0 +1,151 @@
"""Append-only task artifact writer."""
from __future__ import annotations
import json
import os
import shutil
import uuid
from contextlib import AbstractContextManager
from pathlib import Path
from .errors import TaskOutputExists
from .models import QuarantineItem, RawDocument
def _write_json(path: Path, value: object) -> None:
path.write_text(
json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
class TaskOutput(AbstractContextManager["TaskOutput"]):
def __init__(
self,
output_root: Path,
source_code: str,
task_id: str,
tenant_id: str | None = None,
) -> None:
source_directory = output_root / source_code
self.final_directory = (
source_directory / tenant_id / task_id
if tenant_id
else source_directory / task_id
)
self.staging_directory = self.final_directory.with_name(
f".{task_id}.tmp-{uuid.uuid4().hex}"
)
self.raw_directory = self.staging_directory / "raw"
self._standard = None
self._quarantine = None
def __enter__(self) -> "TaskOutput":
if self.final_directory.exists():
raise TaskOutputExists(
f"Task output already exists: {self.final_directory}"
)
self.raw_directory.mkdir(parents=True, exist_ok=False)
os.chmod(self.staging_directory, 0o700)
os.chmod(self.raw_directory, 0o700)
self._standard = (self.staging_directory / "standard.ndjson").open(
"x", encoding="utf-8"
)
self._quarantine = (self.staging_directory / "quarantine.ndjson").open(
"x", encoding="utf-8"
)
os.chmod(self.staging_directory / "standard.ndjson", 0o600)
os.chmod(self.staging_directory / "quarantine.ndjson", 0o600)
return self
def __exit__(self, exception_type, _exception, _traceback) -> bool:
if self._standard:
self._standard.close()
if self._quarantine:
self._quarantine.close()
if exception_type is not None:
shutil.rmtree(self.staging_directory, ignore_errors=True)
return False
def write_raw(
self, document: RawDocument, *, task_id: str, connector_version: str
) -> None:
extensions = {
"application/json": "json",
"application/ld+json": "json",
"application/x-ndjson": "ndjson",
"text/csv": "csv",
"text/html": "html",
"text/plain": "txt",
}
extension = extensions.get(document.content_type, "bin")
body_path = self.raw_directory / f"{document.payload_hash}.{extension}"
metadata_path = self.raw_directory / f"{document.payload_hash}.metadata.json"
if not body_path.exists():
body_path.write_bytes(document.body)
os.chmod(body_path, 0o600)
if not metadata_path.exists():
_write_json(
metadata_path,
{
"source_code": document.source_code,
"source_url": document.source_url,
"content_type": document.content_type,
"bytes": len(document.body),
"sha256": document.payload_hash,
"raw_snapshot_id": document.snapshot_id,
"collected_at": document.collected_at,
"collector_task_id": task_id,
"connector_version": connector_version,
},
)
os.chmod(metadata_path, 0o600)
def write_standard(self, record: dict) -> None:
assert self._standard is not None
self._standard.write(
json.dumps(
record, ensure_ascii=False, sort_keys=True, separators=(",", ":")
)
+ "\n"
)
def write_quarantine(self, item: QuarantineItem) -> None:
assert self._quarantine is not None
self._quarantine.write(
json.dumps(
{
"source_code": item.source_code,
"raw_snapshot_id": item.raw_snapshot_id,
"source_record_id": item.source_record_id,
"error_code": item.error_code,
"error": item.error,
},
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
)
+ "\n"
)
def commit(self, summary: dict) -> Path:
assert self._standard is not None and self._quarantine is not None
self._standard.flush()
self._quarantine.flush()
os.fsync(self._standard.fileno())
os.fsync(self._quarantine.fileno())
_write_json(self.staging_directory / "summary.json", summary)
os.chmod(self.staging_directory / "summary.json", 0o600)
self._standard.close()
self._quarantine.close()
self._standard = None
self._quarantine = None
self.final_directory.parent.mkdir(parents=True, exist_ok=True)
os.chmod(self.final_directory.parent, 0o700)
if self.final_directory.exists():
raise TaskOutputExists(
f"Task output already exists: {self.final_directory}"
)
self.staging_directory.replace(self.final_directory)
return self.final_directory
@@ -0,0 +1,148 @@
"""HTTP transport that connects only to the public IPs it validated."""
from __future__ import annotations
import socket
import ssl
import select
from collections.abc import Iterable
import httpcore
import httpx
from .security import Resolver, resolve_public_endpoints
class PinnedNetworkStream(httpcore.NetworkStream):
def __init__(self, sock: socket.socket) -> None:
self._socket = sock
def read(self, max_bytes: int, timeout: float | None = None) -> bytes:
try:
self._socket.settimeout(timeout)
return self._socket.recv(max_bytes)
except socket.timeout as exc:
raise httpcore.ReadTimeout("validated source read timed out") from exc
except OSError as exc:
raise httpcore.ReadError("validated source read failed") from exc
def write(self, buffer: bytes, timeout: float | None = None) -> None:
try:
self._socket.settimeout(timeout)
self._socket.sendall(buffer)
except socket.timeout as exc:
raise httpcore.WriteTimeout("validated source write timed out") from exc
except OSError as exc:
raise httpcore.WriteError("validated source write failed") from exc
def close(self) -> None:
self._socket.close()
def start_tls(
self,
ssl_context: ssl.SSLContext,
server_hostname: str | None = None,
timeout: float | None = None,
) -> httpcore.NetworkStream:
try:
self._socket.settimeout(timeout)
wrapped = ssl_context.wrap_socket(
self._socket, server_hostname=server_hostname
)
except socket.timeout as exc:
self.close()
raise httpcore.ConnectTimeout(
"validated source TLS handshake timed out"
) from exc
except OSError as exc:
self.close()
raise httpcore.ConnectError(
"validated source TLS handshake failed"
) from exc
return PinnedNetworkStream(wrapped)
def get_extra_info(self, info: str) -> object | None:
if info == "ssl_object" and isinstance(self._socket, ssl.SSLSocket):
return self._socket
if info == "client_addr":
return self._socket.getsockname()
if info == "server_addr":
return self._socket.getpeername()
if info == "socket":
return self._socket
if info == "is_readable":
try:
return bool(select.select([self._socket], [], [], 0)[0])
except OSError:
return True
return None
class PinnedNetworkBackend(httpcore.NetworkBackend):
def __init__(self, resolver: Resolver = socket.getaddrinfo) -> None:
self._resolver = resolver
def connect_tcp(
self,
host: str,
port: int,
timeout: float | None = None,
local_address: str | None = None,
socket_options: Iterable[tuple] | None = None,
) -> httpcore.NetworkStream:
endpoints = resolve_public_endpoints(host, port, resolver=self._resolver)
last_error: OSError | None = None
for family, socket_type, protocol, socket_address in endpoints:
sock = socket.socket(family, socket_type, protocol)
try:
sock.settimeout(timeout)
if local_address:
bind_address = (
(local_address, 0, 0, 0)
if family == socket.AF_INET6
else (local_address, 0)
)
sock.bind(bind_address)
for option in socket_options or ():
sock.setsockopt(*option)
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
sock.connect(socket_address)
return PinnedNetworkStream(sock)
except socket.timeout as exc:
sock.close()
raise httpcore.ConnectTimeout(
"validated source connection timed out"
) from exc
except OSError as exc:
last_error = exc
sock.close()
raise httpcore.ConnectError(
"validated source connection failed"
) from last_error
def connect_unix_socket(
self,
path: str,
timeout: float | None = None,
socket_options: Iterable[tuple] | None = None,
) -> httpcore.NetworkStream:
del path, timeout, socket_options
raise httpcore.UnsupportedProtocol(
"Unix sockets are disabled for source collection"
)
class PinnedHTTPTransport(httpx.HTTPTransport):
def __init__(self, resolver: Resolver = socket.getaddrinfo) -> None:
super().__init__(trust_env=False, retries=0)
self._pool.close()
self._pool = httpcore.ConnectionPool(
ssl_context=ssl.create_default_context(),
max_connections=2,
max_keepalive_connections=2,
keepalive_expiry=5.0,
http1=True,
http2=False,
retries=0,
network_backend=PinnedNetworkBackend(resolver),
)
@@ -0,0 +1,255 @@
import json
from dataclasses import replace
import pytest
from edu_crawler.connectors import CONNECTOR_TYPES, connector_for
from edu_crawler.errors import SourceStructureChanged
from edu_crawler.models import ParsedItem
from edu_crawler.schema import validate_record
def parsed(value: dict, *, payload_hash: str = "a" * 64) -> ParsedItem:
return ParsedItem(
value=value,
raw_snapshot_id="RAW-BOSS-20260824-ABCDEF012345",
source_payload_hash=payload_hash,
collected_at="2026-08-24T10:00:00+08:00",
source_url="file:///authorized.ndjson",
)
def test_all_nine_sources_have_concrete_connector_types() -> None:
assert set(CONNECTOR_TYPES) == {
"BOSS",
"ZHAOPIN",
"BRZP",
"NCSS",
"MOHRSS",
"IGUOPIN",
"HZRC",
"SCHOOL_EMPLOYMENT",
"SCHOOL_INTERNSHIP",
}
assert all(
connector_for(code).describe()["source_code"] == code
for code in CONNECTOR_TYPES
)
def test_external_record_normalizes_and_redacts_contact() -> None:
connector = connector_for("BOSS")
source = {
"source_record_id": "BOSS-001",
"source_url": "https://www.bosszhipin.com/job_detail/BOSS-001.html",
"title": "云原生开发工程师",
"description": "<p>负责容器平台开发,联系 13800138000</p>",
"company_name": "示例数字科技有限公司",
"region_code": "330100",
"employment_type": "全职",
"published_at": "2026-08-23T08:00:00+08:00",
"salary_min": "12000",
"salary_max": "18000",
"salary_period": "month",
"skills": ["Docker", "Kubernetes", "Docker"],
}
record = connector.normalize(
parsed(source),
task_id="JOB-20260824-BOSS-001",
data_version="V2026.08.24",
default_region_code=None,
)
validate_record(connector.policy.target_schema, record)
assert record["source_platform"] == "BOSS"
assert record["job_description"] == "负责容器平台开发,联系 [CONTACT_REDACTED]"
assert record["skills"] == ["Docker", "Kubernetes"]
assert record["source_payload_hash"] == "a" * 64
def test_annual_salary_is_normalized_to_monthly_and_unknown_status_stays_unknown() -> (
None
):
connector = connector_for("BOSS")
source = {
"source_record_id": "BOSS-ANNUAL-001",
"source_url": "https://www.bosszhipin.com/job_detail/BOSS-ANNUAL-001.html",
"title": "年度薪资测试岗位",
"description": "用于验证统一月薪折算",
"company_name": "示例企业",
"region_code": "330100",
"salary_min": 120000,
"salary_max": 240000,
"salary_period": "year",
"status": "new-upstream-code",
}
record = connector.normalize(
parsed(source),
task_id="JOB-20260824-BOSS-ANNUAL",
data_version="V2026.08.24",
default_region_code=None,
)
assert record["salary_min"] == 10000
assert record["salary_max"] == 20000
assert record["salary_period"] == "year"
assert record["status"] == "unknown"
def test_content_hash_ignores_collection_metadata() -> None:
connector = connector_for("BOSS")
source = {
"source_record_id": "BOSS-001",
"source_url": "https://www.bosszhipin.com/job_detail/BOSS-001.html",
"title": "云原生开发工程师",
"description": "负责容器平台开发",
"company_name": "示例数字科技有限公司",
"region_code": "330100",
}
first = connector.normalize(
parsed(source, payload_hash="a" * 64),
task_id="JOB-20260824-BOSS-001",
data_version="V2026.08.24",
default_region_code=None,
)
second_item = replace(
parsed(source, payload_hash="b" * 64),
raw_snapshot_id="RAW-BOSS-20260825-012345ABCDEF",
collected_at="2026-08-25T10:00:00+08:00",
)
second = connector.normalize(
second_item,
task_id="JOB-20260825-BOSS-001",
data_version="V2026.08.25",
default_region_code=None,
)
assert first["content_hash"] == second["content_hash"]
assert first["source_payload_hash"] != second["source_payload_hash"]
def test_source_record_url_must_match_source_allowlist() -> None:
connector = connector_for("BOSS")
source = {
"source_record_id": "BOSS-001",
"source_url": "https://evil.example/job/1",
"title": "测试岗位",
"description": "测试职责",
"company_name": "测试企业",
"region_code": "330100",
}
try:
connector.normalize(
parsed(source),
task_id="JOB-20260824-BOSS-001",
data_version="V2026.08.24",
default_region_code=None,
)
except Exception as exc:
assert getattr(exc, "code", None) == "SOURCE_STRUCTURE_CHANGED"
else:
raise AssertionError("unapproved source URL was accepted")
def test_declared_source_must_match_selected_connector() -> None:
connector = connector_for("BOSS")
source = {
"source_platform": "ZHAOPIN",
"source_record_id": "ZL-001",
"source_url": "https://www.bosszhipin.com/job_detail/1.html",
"title": "测试岗位",
"description": "测试岗位职责",
"company_name": "测试企业",
"region_code": "330100",
}
with pytest.raises(SourceStructureChanged) as caught:
connector.normalize(
parsed(source),
task_id="JOB-20260824-BOSS-001",
data_version="V2026.08.24",
default_region_code=None,
)
assert getattr(caught.value, "code", None) == "SOURCE_STRUCTURE_CHANGED"
@pytest.mark.parametrize(
("source_code", "source_url"),
[
("BOSS", "https://www.bosszhipin.com/job_detail/1.html"),
("ZHAOPIN", "https://www.zhaopin.com/jobdetail/1.htm"),
("BRZP", "https://brzp.chinasme.cn/brzp/job/1"),
("NCSS", "https://www.ncss.cn/student/jobs/1"),
("MOHRSS", "http://job.mohrss.gov.cn/cjobs/jobinfo/1"),
("IGUOPIN", "https://job.iguopin.com/job/detail?id=1"),
("HZRC", "https://www.hzrc.com/job/1"),
],
)
def test_each_external_source_normalizes_authorized_feed(
source_code: str, source_url: str
) -> None:
connector = connector_for(source_code)
record = connector.normalize(
parsed(
{
"source_record_id": f"{source_code}-001",
"source_url": source_url,
"title": "测试岗位",
"description": "测试岗位职责",
"company_name": "测试企业",
"region_code": "330100",
}
),
task_id=f"JOB-20260824-{source_code}-001",
data_version="V2026.08.24",
default_region_code=None,
)
validate_record(connector.policy.target_schema, record)
assert record["source_platform"] == source_code
def test_source_url_secrets_are_redacted() -> None:
connector = connector_for("BOSS")
record = connector.normalize(
parsed(
{
"source_record_id": "BOSS-001",
"source_url": "https://www.bosszhipin.com/job_detail/1.html?token=secret&city=330100#private",
"title": "测试岗位",
"description": "测试岗位职责",
"company_name": "测试企业",
"region_code": "330100",
}
),
task_id="JOB-20260824-BOSS-001",
data_version="V2026.08.24",
default_region_code=None,
)
assert "secret" not in record["source_url"]
assert "private" not in record["source_url"]
assert "city=330100" in record["source_url"]
def test_schema_org_job_posting_mapping() -> None:
connector = connector_for("BRZP")
source = {
"@type": "JobPosting",
"identifier": {"value": "BRZP-001"},
"url": "https://brzp.chinasme.cn/brzp/job/BRZP-001",
"title": "跨境电商运营实习生",
"description": "协助店铺运营和数据分析",
"hiringOrganization": {"name": "示例跨境服务有限公司"},
"jobLocation": {"address": {"addressLocality": "杭州市"}},
"employmentType": "internship",
"datePosted": "2026-08-23T08:00:00+08:00",
"baseSalary": {
"currency": "CNY",
"value": {"minValue": 3500, "maxValue": 5000, "unitText": "MONTH"},
},
}
record = connector.normalize(
parsed(source),
task_id="JOB-20260824-BRZP-001",
data_version="V2026.08.24",
default_region_code="330100",
)
validate_record(connector.policy.target_schema, record)
assert record["employment_type"] == "internship"
assert record["salary_period"] == "month"
assert json.loads(json.dumps(record, ensure_ascii=False)) == record
+62
View File
@@ -0,0 +1,62 @@
import json
import pytest
from edu_crawler.errors import SourceStructureChanged
from edu_crawler.models import RawDocument
from edu_crawler.parsing import discover_job_links, parse_document
from edu_crawler.policies import source_policy
def test_parse_schema_org_job_posting() -> None:
body = b"""
<html><head><script type="application/ld+json">
{"@context":"https://schema.org","@type":"JobPosting","identifier":{"value":"BRZP-1"},
"url":"https://brzp.chinasme.cn/job/BRZP-1","title":"Test","description":"Description",
"hiringOrganization":{"name":"Example"}}
</script></head></html>
"""
document = RawDocument.create(
"BRZP", "https://brzp.chinasme.cn/job/BRZP-1", "text/html", body
)
records = parse_document(document, allow_html=True)
assert records[0]["@type"] == "JobPosting"
def test_discover_links_uses_versioned_patterns_and_deduplicates() -> None:
body = b"""
<a href="/job/1">one</a>
<a href="/job/1">duplicate</a>
<a href="/company/1">company</a>
"""
document = RawDocument.create(
"BRZP", "https://brzp.chinasme.cn/", "text/html", body
)
assert discover_job_links(document, source_policy("BRZP")) == [
"https://brzp.chinasme.cn/job/1"
]
def test_invalid_ndjson_reports_line_without_payload() -> None:
document = RawDocument.create(
"BOSS",
"file:///authorized.ndjson",
"application/x-ndjson",
b'{"id":"one"}\nnot-json\n',
)
with pytest.raises(SourceStructureChanged, match="line 2") as caught:
parse_document(document, allow_html=False)
assert "not-json" not in str(caught.value)
def test_json_collection_wrappers() -> None:
document = RawDocument.create(
"BOSS",
"file:///authorized.json",
"application/json",
json.dumps({"data": {"items": [{"id": "one"}, {"id": "two"}]}}).encode(),
)
assert [record["id"] for record in parse_document(document, allow_html=False)] == [
"one",
"two",
]
+313
View File
@@ -0,0 +1,313 @@
import json
from dataclasses import replace
from pathlib import Path
import pytest
from edu_crawler.errors import (
AuthorizationRequired,
ConfigurationError,
PiiRejected,
TaskOutputExists,
)
from edu_crawler.models import RawDocument
from edu_crawler.runner import CrawlerRunner, RunConfig
from edu_crawler.schema import validate_record
def run_config(
tmp_path: Path, source: str, input_path: Path, data_version: str
) -> RunConfig:
return RunConfig(
source_code=source,
input_location=str(input_path),
task_id=f"JOB-20260824-{source.replace('_', '-')}-001",
data_version=data_version,
output_root=tmp_path / "output",
authorization_reference="DPA-TEST-REF-001",
tenant_id="TENANT-HZVTC" if source.startswith("SCHOOL_") else None,
)
def read_ndjson(path: Path) -> list[dict]:
return [json.loads(line) for line in path.read_text().splitlines() if line]
def test_external_authorized_file_creates_append_only_artifacts(tmp_path: Path) -> None:
source = tmp_path / "boss.ndjson"
source.write_text(
json.dumps(
{
"source_record_id": "BOSS-001",
"source_url": "https://www.bosszhipin.com/job_detail/BOSS-001.html",
"title": "云原生开发工程师",
"description": "负责容器平台开发",
"company_name": "示例数字科技有限公司",
"region_code": "330100",
"employment_type": "full_time",
},
ensure_ascii=False,
)
+ "\n"
)
config = run_config(tmp_path, "BOSS", source, "V2026.08.24")
output = CrawlerRunner(config).run()
summary = json.loads((output / "summary.json").read_text())
records = read_ndjson(output / "standard.ndjson")
assert summary["status"] == "COMPLETED"
assert summary["standard_records"] == 1
assert len(list((output / "raw").glob("*.metadata.json"))) == 1
assert len(records) == 1
validate_record("external-job-source.schema.json", records[0])
with pytest.raises(TaskOutputExists):
CrawlerRunner(config).run()
def test_missing_external_authorization_is_blocked(tmp_path: Path) -> None:
source = tmp_path / "boss.json"
source.write_text("{}")
config = run_config(tmp_path, "BOSS", source, "V2026.08.24")
config = replace(config, authorization_reference=None)
with pytest.raises(AuthorizationRequired):
CrawlerRunner(config).run()
def test_network_url_is_disabled_without_explicit_flag(tmp_path: Path) -> None:
config = RunConfig(
source_code="BRZP",
input_location="https://brzp.chinasme.cn/jobs",
task_id="JOB-20260824-BRZP-001",
data_version="V2026.08.24",
output_root=tmp_path,
authorization_reference="AUTH-BRZP-001",
)
with pytest.raises(ConfigurationError):
CrawlerRunner(config).run()
def test_explicit_zero_record_limit_is_rejected(tmp_path: Path) -> None:
source = tmp_path / "boss.json"
source.write_text("{}")
config = replace(run_config(tmp_path, "BOSS", source, "V2026.08.24"), max_records=0)
with pytest.raises(ConfigurationError, match="max_records"):
CrawlerRunner(config).run()
def test_explicit_zero_page_limit_is_rejected_without_request(tmp_path: Path) -> None:
config = RunConfig(
source_code="BRZP",
input_location="https://brzp.chinasme.cn/jobs",
task_id="JOB-20260824-BRZP-001",
data_version="V2026.08.24",
output_root=tmp_path,
authorization_reference="AUTH-BRZP-001",
allow_network=True,
approved_hosts=("brzp.chinasme.cn",),
contact="crawler@example.edu.cn",
max_pages=0,
)
with pytest.raises(ConfigurationError, match="max_pages"):
CrawlerRunner(config).run()
def test_local_file_size_limit_is_enforced_before_output(tmp_path: Path) -> None:
source = tmp_path / "oversized.json"
source.write_bytes(b"x" * (5 * 1024 * 1024 + 1))
config = run_config(tmp_path, "BOSS", source, "V2026.08.24")
with pytest.raises(ConfigurationError, match="size limit"):
CrawlerRunner(config).run()
assert not config.output_root.exists()
def test_invalid_utf8_is_quarantined_without_payload_echo(tmp_path: Path) -> None:
source = tmp_path / "invalid.ndjson"
source.write_bytes(b"\xff\xfe")
output = CrawlerRunner(run_config(tmp_path, "BOSS", source, "V2026.08.24")).run()
quarantine = read_ndjson(output / "quarantine.ndjson")
assert quarantine[0]["error_code"] == "SOURCE_STRUCTURE_CHANGED"
assert quarantine[0]["error"] == "Structured source is not valid UTF-8"
def test_record_limit_stops_network_document_generator_early(tmp_path: Path) -> None:
config = RunConfig(
source_code="BRZP",
input_location="https://brzp.chinasme.cn/jobs",
task_id="JOB-20260824-BRZP-LIMIT",
data_version="V2026.08.24",
output_root=tmp_path,
authorization_reference="AUTH-BRZP-001",
allow_network=True,
approved_hosts=("brzp.chinasme.cn",),
contact="crawler@example.edu.cn",
max_records=1,
)
consumed: list[str] = []
def documents():
for suffix in ("one", "two"):
consumed.append(suffix)
yield RawDocument.create(
"BRZP",
f"https://brzp.chinasme.cn/job/{suffix}",
"application/json",
json.dumps(
[
{
"source_record_id": suffix,
"source_url": f"https://brzp.chinasme.cn/job/{suffix}",
"title": "测试岗位",
"description": "测试职责",
"company_name": "测试企业",
"region_code": "330100",
}
]
).encode(),
)
runner = CrawlerRunner(config)
runner._network_documents = documents
output = runner.run()
assert consumed == ["one"]
assert json.loads((output / "summary.json").read_text())["raw_documents"] == 1
def test_valid_school_employment_record(tmp_path: Path) -> None:
source = tmp_path / "employment.ndjson"
source.write_text(
json.dumps(
{
"tenant_id": "TENANT-HZVTC",
"student_token": "STU_" + "a" * 32,
"cohort": 2026,
"college_code": "C01",
"program_code": "510203",
"employment_status": "employed",
"destination_type": "enterprise",
"enterprise_token": "ENT_" + "b" * 24,
"confirmed_at": "2026-08-23T08:00:00+08:00",
"confirmation_role": "system",
"source_system": "EMPLOYMENT-VIEW",
"source_record_id": "EMP-001",
"source_updated_at": "2026-08-23T08:00:00+08:00",
},
ensure_ascii=False,
)
+ "\n"
)
output = CrawlerRunner(
run_config(tmp_path, "SCHOOL_EMPLOYMENT", source, "SCH-EMP-20260824-01")
).run()
record = read_ndjson(output / "standard.ndjson")[0]
validate_record("school-employment.schema.json", record)
assert record["tenant_id"] == "TENANT-HZVTC"
def test_school_connector_requires_expected_tenant(tmp_path: Path) -> None:
source = tmp_path / "employment.ndjson"
source.write_text("{}\n")
config = replace(
run_config(tmp_path, "SCHOOL_EMPLOYMENT", source, "SCH-EMP-20260824-01"),
tenant_id=None,
)
with pytest.raises(ConfigurationError, match="tenant-id"):
CrawlerRunner(config).run()
def test_school_connector_rejects_cross_tenant_batch_before_raw(tmp_path: Path) -> None:
source = tmp_path / "employment.ndjson"
source.write_text(
json.dumps({"tenant_id": "TENANT-OTHER", "source_record_id": "EMP-001"}) + "\n"
)
config = run_config(tmp_path, "SCHOOL_EMPLOYMENT", source, "SCH-EMP-20260824-01")
with pytest.raises(ConfigurationError, match="outside the configured tenant"):
CrawlerRunner(config).run()
assert not any(path.is_file() for path in config.output_root.rglob("*"))
def test_school_pii_is_rejected_before_raw_persistence(tmp_path: Path) -> None:
source = tmp_path / "employment.ndjson"
source.write_text(
json.dumps(
{
"tenant_id": "TENANT-HZVTC",
"source_record_id": "EMP-001",
"profile": {"student_name": "敏感姓名"},
},
ensure_ascii=False,
)
+ "\n"
)
config = run_config(tmp_path, "SCHOOL_EMPLOYMENT", source, "SCH-EMP-20260824-01")
with pytest.raises(PiiRejected):
CrawlerRunner(config).run()
assert not any(config.output_root.rglob(config.task_id))
def test_valid_school_internship_record(tmp_path: Path) -> None:
source = tmp_path / "internship.ndjson"
source.write_text(
json.dumps(
{
"tenant_id": "TENANT-HZVTC",
"internship_id": "INT-001",
"student_token": "STU_" + "a" * 32,
"program_code": "510203",
"enterprise_token": "ENT_" + "b" * 24,
"job_title": "软件开发实习生",
"start_date": "2026-07-01",
"status": "active",
"source_system": "INTERNSHIP-API",
"source_record_id": "INT-001",
"source_updated_at": "2026-08-23T08:00:00+08:00",
},
ensure_ascii=False,
)
+ "\n"
)
output = CrawlerRunner(
run_config(tmp_path, "SCHOOL_INTERNSHIP", source, "SCH-INT-20260824-01")
).run()
record = read_ndjson(output / "standard.ndjson")[0]
validate_record("school-internship.schema.json", record)
assert record["internship_id"] == "INT-001"
assert output.parts[-3:] == (
"SCHOOL_INTERNSHIP",
"TENANT-HZVTC",
"JOB-20260824-SCHOOL-INTERNSHIP-001",
)
def test_school_content_hash_ignores_sync_metadata(tmp_path: Path) -> None:
first_source = tmp_path / "employment-one.ndjson"
second_source = tmp_path / "employment-two.ndjson"
base = {
"tenant_id": "TENANT-HZVTC",
"student_token": "STU_" + "a" * 32,
"cohort": 2026,
"college_code": "C01",
"program_code": "510203",
"employment_status": "further_study",
"destination_type": "further_study",
"confirmed_at": "2026-08-23T08:00:00+08:00",
"confirmation_role": "system",
"source_system": "EMPLOYMENT-VIEW",
"source_record_id": "EMP-001",
}
first_source.write_text(
json.dumps({**base, "source_updated_at": "2026-08-23T08:00:00+08:00"}) + "\n"
)
second_source.write_text(
json.dumps({**base, "source_updated_at": "2026-08-24T08:00:00+08:00"}) + "\n"
)
first = CrawlerRunner(
run_config(tmp_path, "SCHOOL_EMPLOYMENT", first_source, "SCH-EMP-20260824-01")
).run()
second_config = replace(
run_config(tmp_path, "SCHOOL_EMPLOYMENT", second_source, "SCH-EMP-20260824-01"),
task_id="JOB-20260824-SCHOOL-EMP-002",
)
second = CrawlerRunner(second_config).run()
assert (
read_ndjson(first / "standard.ndjson")[0]["content_hash"]
== read_ndjson(second / "standard.ndjson")[0]["content_hash"]
)
@@ -0,0 +1,22 @@
import json
from importlib.resources import files
from pathlib import Path
from jsonschema import Draft202012Validator
SCHEMA_NAMES = (
"external-job-source.schema.json",
"school-employment.schema.json",
"school-internship.schema.json",
)
def test_packaged_schemas_match_canonical_demo_contracts() -> None:
canonical_directory = Path(__file__).resolve().parents[2] / "demo" / "schemas"
for name in SCHEMA_NAMES:
canonical = json.loads((canonical_directory / name).read_text())
packaged = json.loads(
files("edu_crawler.schemas").joinpath(name).read_text(encoding="utf-8")
)
assert packaged == canonical
Draft202012Validator.check_schema(packaged)
+270
View File
@@ -0,0 +1,270 @@
from dataclasses import replace
import os
import httpx
import pytest
from edu_crawler.errors import (
AuthOrChallenge,
ConfigurationError,
HostNotAllowed,
PrivateAddressBlocked,
RobotsDenied,
SourceStructureChanged,
)
from edu_crawler.http import SafeHttpClient
from edu_crawler.policies import source_policy
from edu_crawler.security import (
prohibited_school_keys,
prohibited_school_values,
redact_url,
safe_record_reference,
validate_public_url,
)
from edu_crawler.transport import PinnedNetworkBackend
def public_resolver(_host: str, _port: int, **_kwargs: object) -> list[tuple]:
return [(2, 1, 6, "", ("93.184.216.34", 443))]
def private_resolver(_host: str, _port: int, **_kwargs: object) -> list[tuple]:
return [(2, 1, 6, "", ("127.0.0.1", 443))]
def test_url_allowlist_and_ssrf_controls() -> None:
assert (
validate_public_url(
"https://brzp.chinasme.cn/jobs?page=1",
("brzp.chinasme.cn",),
resolver=public_resolver,
)
== "https://brzp.chinasme.cn/jobs?page=1"
)
with pytest.raises(HostNotAllowed):
validate_public_url(
"https://evil.example/jobs",
("brzp.chinasme.cn",),
resolver=public_resolver,
)
credential_url = "https://" + "user" + ":" + "example" + "@brzp.chinasme.cn/jobs"
with pytest.raises(HostNotAllowed):
validate_public_url(
credential_url, ("brzp.chinasme.cn",), resolver=public_resolver
)
with pytest.raises(PrivateAddressBlocked):
validate_public_url(
"https://brzp.chinasme.cn/jobs",
("brzp.chinasme.cn",),
resolver=private_resolver,
)
with pytest.raises(PrivateAddressBlocked):
validate_public_url(
"https://127.0.0.1/jobs", ("127.0.0.1",), resolver=public_resolver
)
def test_url_redaction_removes_credentials_and_fragment() -> None:
credential_url = "https://" + "user" + ":" + "example" + "@example.com"
value = redact_url(credential_url + "/jobs?token=abc&city=330100#private")
assert value == "https://example.com/jobs?token=%5BREDACTED%5D&city=330100"
assert "pass" not in value
assert "abc" not in value
assert "private" not in value
def test_record_reference_is_sanitized_before_quarantine_or_ids() -> None:
assert safe_record_reference("contact student@example.com") is None
assert (
safe_record_reference(
{"value": "https://example.com/job/1?token=secret#private"}
)
== "https://example.com/job/1?token=%5BREDACTED%5D"
)
def test_nested_school_pii_detection() -> None:
record = {
"student_token": "STU_x",
"profile": {"student_name": "不应进入平台"},
"student": {"name": "不应进入平台"},
"rows": [{"phone": "1"}],
}
assert prohibited_school_keys(record) == [
"profile.student_name",
"rows[0].phone",
"student.name",
]
def test_school_pii_value_detection_reports_paths_only() -> None:
record = {
"student_token": "STU_" + "a" * 32,
"source_record_id": "EMP-001",
"comment": "联系 13800138000 或 student@example.com",
}
assert prohibited_school_values(record) == ["comment"]
def test_http_client_rejects_unversioned_approved_host() -> None:
policy = replace(source_policy("BRZP"), minimum_interval_seconds=0)
with pytest.raises(ConfigurationError):
SafeHttpClient(
policy,
contact="crawler@example.edu.cn",
approved_hosts=("new-host.example",),
resolver=public_resolver,
transport=httpx.MockTransport(lambda _request: httpx.Response(200)),
)
def test_robots_denial_stops_before_target_request() -> None:
requests: list[str] = []
def handler(request: httpx.Request) -> httpx.Response:
requests.append(str(request.url))
assert request.url.path == "/robots.txt"
return httpx.Response(
200,
headers={"content-type": "text/plain"},
text="User-agent: *\nDisallow: /private",
)
policy = replace(source_policy("BRZP"), minimum_interval_seconds=0)
with SafeHttpClient(
policy,
contact="crawler@example.edu.cn",
approved_hosts=("brzp.chinasme.cn",),
resolver=public_resolver,
transport=httpx.MockTransport(handler),
) as client:
with pytest.raises(RobotsDenied):
client.fetch("https://brzp.chinasme.cn/private")
assert requests == ["https://brzp.chinasme.cn/robots.txt"]
def test_challenge_page_stops_without_bypass() -> None:
def handler(request: httpx.Request) -> httpx.Response:
if request.url.path == "/robots.txt":
return httpx.Response(
200,
headers={"content-type": "text/plain"},
text="User-agent: *\nAllow: /",
)
return httpx.Response(
200, headers={"content-type": "text/html"}, text="<title>安全验证</title>"
)
policy = replace(source_policy("BRZP"), minimum_interval_seconds=0)
with SafeHttpClient(
policy,
contact="crawler@example.edu.cn",
approved_hosts=("brzp.chinasme.cn",),
resolver=public_resolver,
transport=httpx.MockTransport(handler),
) as client:
with pytest.raises(AuthOrChallenge):
client.fetch("https://brzp.chinasme.cn/job/1")
def test_network_errors_do_not_echo_sensitive_urls() -> None:
def handler(request: httpx.Request) -> httpx.Response:
raise httpx.ConnectError("failed token=secret-value", request=request)
policy = replace(
source_policy("BRZP"), minimum_interval_seconds=0, robots_required=False
)
with SafeHttpClient(
policy,
contact="crawler@example.edu.cn",
approved_hosts=("brzp.chinasme.cn",),
resolver=public_resolver,
transport=httpx.MockTransport(handler),
) as client:
with pytest.raises(SourceStructureChanged) as caught:
client.fetch("https://brzp.chinasme.cn/jobs?token=secret-value")
assert str(caught.value) == "Source network request failed"
def test_redirect_is_revalidated_against_allowlist() -> None:
def handler(request: httpx.Request) -> httpx.Response:
if request.url.path == "/robots.txt":
return httpx.Response(
200,
headers={"content-type": "text/plain"},
text="User-agent: *\nAllow: /",
)
return httpx.Response(302, headers={"location": "https://evil.example/private"})
policy = replace(source_policy("BRZP"), minimum_interval_seconds=0)
with SafeHttpClient(
policy,
contact="crawler@example.edu.cn",
approved_hosts=("brzp.chinasme.cn",),
resolver=public_resolver,
transport=httpx.MockTransport(handler),
) as client:
with pytest.raises(HostNotAllowed):
client.fetch("https://brzp.chinasme.cn/job/1")
def test_bearer_token_is_not_sent_to_robots(monkeypatch: pytest.MonkeyPatch) -> None:
observed: list[tuple[str, str | None]] = []
def handler(request: httpx.Request) -> httpx.Response:
observed.append((request.url.path, request.headers.get("authorization")))
if request.url.path == "/robots.txt":
return httpx.Response(
200,
headers={"content-type": "text/plain"},
text="User-agent: *\nAllow: /",
)
return httpx.Response(
200, headers={"content-type": "application/json"}, json=[{"id": "one"}]
)
monkeypatch.setenv("TEST_SOURCE_BEARER", "example-token-value")
policy = replace(source_policy("BRZP"), minimum_interval_seconds=0)
with SafeHttpClient(
policy,
contact="crawler@example.edu.cn",
approved_hosts=("brzp.chinasme.cn",),
bearer_token_env="TEST_SOURCE_BEARER",
resolver=public_resolver,
transport=httpx.MockTransport(handler),
) as client:
client.fetch("https://brzp.chinasme.cn/jobs")
assert observed == [
("/robots.txt", None),
("/jobs", "Bearer example-token-value"),
]
assert os.environ["TEST_SOURCE_BEARER"] == "example-token-value"
def test_pinned_backend_connects_to_the_validated_ip(
monkeypatch: pytest.MonkeyPatch,
) -> None:
connected: list[tuple] = []
class FakeSocket:
def settimeout(self, _timeout: float | None) -> None:
pass
def setsockopt(self, *_option: object) -> None:
pass
def bind(self, _address: tuple) -> None:
pass
def connect(self, address: tuple) -> None:
connected.append(address)
def close(self) -> None:
pass
monkeypatch.setattr(
"edu_crawler.transport.socket.socket", lambda *_args: FakeSocket()
)
stream = PinnedNetworkBackend(public_resolver).connect_tcp("brzp.chinasme.cn", 443)
stream.close()
assert connected == [("93.184.216.34", 443)]
@@ -62,6 +62,16 @@ parse(snapshot) 解析为来源 Schema
health() 返回入口可达性、延迟和错误分类
```
参考实现位于 [`../crawler/`](../crawler/),逐来源接入方式和授权门禁见 [`../../docs/crawler-source-implementation.md`](../../docs/crawler-source-implementation.md)。连接器运行时必须遵守以下硬约束:
- 所有外部来源默认禁用,只有来源审批状态为 `approved` 才允许公网运行;
- 商业平台仅接入官方合作 API 或授权数据包,不实现登录自动化、验证码处理、私有接口逆向和反自动化规避;
- 公开页面任务同时执行书面授权、robots.txt、域名 allowlist、公共地址解析和来源级限速检查;
- 重定向后的每个 URL 重新执行相同安全校验,禁止访问回环、内网、链路本地和云元数据地址;
- User-Agent 必须标识本系统并提供真实技术联系邮箱,不伪装普通浏览器;
- Authorization、Cookie 和签名密钥只从 Vault/KMS 引用注入,不写入任务、Raw、日志或异常;
- 登录页、验证码、安全验证、访问封禁和结构变化均触发停止或隔离,不尝试绕过。
连接器发布物必须包含:
- 代码仓库、语义版本、提交摘要和构建镜像摘要;
@@ -221,6 +231,7 @@ ISSUE / WORK_ORDER
## 9. 安全与运维
- 源端凭据使用 Vault/KMS 管理,配置和日志只保存密钥引用;
- 来源授权、条款与 robots 快照作为连接器发布证据;授权到期自动停止调度;
- 采集节点使用独立网络出口策略与最小权限;
- 控制台操作采用 RBAC,并区分运营、开发、发布审批和审计;
- 原始对象启用版本、对象锁和生命周期;
+16 -11
View File
@@ -23,19 +23,21 @@ python3 -m http.server 8080
5. 执行质量校验、企业归一、岗位分类和技能抽取;
6. 通过带 Manifest、Schema、质量报告、血缘和校验和的数据包向学校交付。
## 已纳入的正式数据源
## 已纳入的目标数据源
| 类别 | 数据源 | 原型状态 | 目标数据 |
| 类别 | 数据源 | 生产接入方式 | 目标数据 |
|---|---|---|---|
| 商业平台 | BOSS直聘 | 正式运行 | 岗位、企业、地区、薪资、岗位职责、技能 |
| 商业平台 | 智联招聘 | 正式运行 | 岗位、企业、地区、薪资、岗位职责、技能 |
| 公共平台 | 全国中小企业招聘平台 | 正式运行 | 中小企业岗位、校招、实习和招聘活动 |
| 公共平台 | 国家大学生就业服务平台 | 正式运行 | 高校毕业生岗位、专场招聘和实习 |
| 公共平台 | 中国公共招聘网 | 正式运行 | 公共就业岗位、重点行业和招聘活动 |
| 公共平台 | 国聘 | 正式运行 | 国资央企岗位和校园招聘 |
| 公共平台 | 杭州人才网 | 降速保护 | 杭州都市圈企业和岗位 |
| 校内系统 | 就业管理系统 | 正式运行 | 毕业去向、岗位、企业、薪资和专业相关度 |
| 校内系统 | 实习管理系统 | 正式运行 | 实习岗位、企业、工资、过程和企业评价 |
| 商业平台 | BOSS直聘 | 官方合作 API / 授权数据包 | 岗位、企业、地区、薪资、岗位职责、技能 |
| 商业平台 | 智联招聘 | 官方合作 API / 书面授权数据包 | 岗位、企业、地区、薪资、岗位职责、技能 |
| 公共平台 | 全国中小企业招聘平台 | 官方接口优先;获准公开页 | 中小企业岗位、校招、实习和招聘活动 |
| 公共平台 | 国家大学生就业服务平台 | 官方接口优先;获准公开页 | 高校毕业生岗位、专场招聘和实习 |
| 公共平台 | 中国公共招聘网 | 官方交换 / 提供单位授权 | 公共就业岗位、重点行业和招聘活动 |
| 公共平台 | 国聘 | 官方合作 API / 授权数据包 | 国资央企岗位和校园招聘 |
| 公共平台 | 杭州人才网 | 书面许可接口 / 低速公开页 | 杭州都市圈企业和岗位 |
| 校内系统 | 就业管理系统 | 租户只读视图 / API / 文件 | 毕业去向、岗位、企业、薪资和专业相关度 |
| 校内系统 | 实习管理系统 | 租户 API / 文件 | 实习岗位、企业、工资、过程和企业评价 |
页面上的“正式运行”、记录数、成功率和时间线均为静态产品样板数据,不代表仓库当前正在访问这些站点。生产连接器默认禁止公网访问,必须完成来源授权、robots、域名白名单、限速和小样本结构审批后才能启用。逐来源方案见 [`../../docs/crawler-source-implementation.md`](../../docs/crawler-source-implementation.md),实现位于 [`../crawler/`](../crawler/)。
## 交互范围
@@ -90,6 +92,9 @@ python3 -m http.server 8080
- `schemas/school-employment.schema.json`
- `SCHOOL-EMPLOYMENT V2.2`
- 校内就业事实契约,包含租户隔离和学生令牌化要求
- `schemas/school-internship.schema.json`
- `SCHOOL-INTERNSHIP V2.1`
- 校内实习事实契约,包含租户隔离、学生令牌化、岗位任务和评价证据
- `schemas/package-manifest.schema.json`
- `PACKAGE-MANIFEST V1.2`
- 版本化导入、导出和学校交付包清单
+36
View File
@@ -804,6 +804,42 @@ const contracts = {
["质量阈值", "专业代码合法率100%,必填完整率≥95%,学生专业关联率≥98%。"],
],
},
"school-internship": {
key: "SCHOOL-INTERNSHIP",
title: "校内实习事实数据标准",
version: "V2.1.0",
description: "统一学生实习岗位、企业、起止日期、状态、工资、岗位任务和评价事实,支持租户隔离和敏感身份最小化。",
schemaPath: "./schemas/school-internship.schema.json",
fields: 30,
required: 14,
owner: "平台数据标准委员会",
status: "生产已发布",
release: "2026-08-24",
compatibility: "兼容 V2.0,补充任务与评价证据",
fieldsPreview: [
["tenant_id", "string", "是", "学校租户标识", "平台分配"],
["internship_id", "string", "是", "实习业务编号", "租户内唯一"],
["student_token", "string", "是", "学生不可逆令牌", "禁止明文学号"],
["program_code", "string", "是", "专业代码", "教育部专业目录"],
["enterprise_token", "string", "否", "企业统一标识", "令牌化"],
["job_title", "string", "是", "实习岗位名称", "业务原文"],
["job_tasks", "array<string>", "否", "实习岗位任务", "学校确认"],
["start_date", "date", "是", "开始日期", "YYYY-MM-DD"],
["end_date", "date", "否", "结束日期", "YYYY-MM-DD"],
["status", "string", "是", "实习状态", "受控枚举"],
["salary_monthly", "number", "否", "税前月实习工资", "元/月"],
["evidence_reference", "string", "否", "校内证据引用", "不传附件"],
["source_updated_at", "datetime", "否", "业务更新时间", "增量水位"],
["content_hash", "string", "是", "业务内容哈希", "SHA-256"],
],
rules: [
["租户隔离", "任务、认证、水位、Raw、隔离区和输出目录均按 tenant_id 隔离。"],
["身份最小化", "平台只接收 student_token,拒绝姓名、身份证、手机号、邮箱和明文学号。"],
["企业要求", "进行中或已完成实习必须提供企业令牌或企业名称。"],
["证据引用", "敏感附件保留在学校系统,平台只保存 evidence_reference。"],
["增量幂等", "tenant_id + source_record_id + source_updated_at 形成增量与幂等边界。"],
],
},
"package-manifest": {
key: "PACKAGE-MANIFEST",
title: "数据交付包清单标准",
+2 -1
View File
@@ -395,6 +395,7 @@
<div class="contract-tabs" id="contractTabs">
<button class="active" data-contract="external-job"><span>JOB-SIGNAL</span><strong>外部岗位信号</strong><small>V3.0 · 39字段</small></button>
<button data-contract="school-employment"><span>SCHOOL-EMPLOYMENT</span><strong>校内就业事实</strong><small>V2.2 · 32字段</small></button>
<button data-contract="school-internship"><span>SCHOOL-INTERNSHIP</span><strong>校内实习事实</strong><small>V2.1 · 30字段</small></button>
<button data-contract="package-manifest"><span>PACKAGE-MANIFEST</span><strong>交付包清单</strong><small>V1.2 · 21字段</small></button>
</div>
<div id="contractContent"></div>
@@ -425,7 +426,7 @@
<small>最大 2GB;大文件将自动分片并计算 SHA-256</small>
</label>
<div class="import-configuration">
<label><span>目标契约</span><select id="importContract"><option value="external-job">JOB-SIGNAL V3.0</option><option value="school-employment">SCHOOL-EMPLOYMENT V2.2</option></select></label>
<label><span>目标契约</span><select id="importContract"><option value="external-job">JOB-SIGNAL V3.0</option><option value="school-employment">SCHOOL-EMPLOYMENT V2.2</option><option value="school-internship">SCHOOL-INTERNSHIP V2.1</option></select></label>
<label><span>导入模式</span><select id="importMode"><option>新增或更新 UPSERT</option><option>初次全量 FULL</option><option>仅质量验证 DRY-RUN</option></select></label>
</div>
<div class="import-preview" id="importPreview">
Binary file not shown.

Before

Width:  |  Height:  |  Size: 414 KiB

After

Width:  |  Height:  |  Size: 360 KiB

+1
View File
@@ -17,4 +17,5 @@
|---|---|---|
| `JOB-SIGNAL` | 3.0.0 | 商业招聘平台、公共就业平台、活动聚合平台 |
| `SCHOOL-EMPLOYMENT` | 2.2.0 | 学校就业管理系统、数据库视图、API和标准文件 |
| `SCHOOL-INTERNSHIP` | 2.1.0 | 学校实习管理系统、API和标准文件 |
| `PACKAGE-MANIFEST` | 1.2.0 | 导入、导出、租户交付和归档;新增可选发布候选编号与状态 |
@@ -0,0 +1,222 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://schemas.edu-industry.cn/school-internship/2.1.0/schema.json",
"title": "SCHOOL-INTERNSHIP V2.1 校内实习事实",
"description": "职业院校学生实习事实统一契约,采用租户隔离、身份最小化和证据引用设计。",
"type": "object",
"additionalProperties": false,
"required": [
"tenant_id",
"record_id",
"internship_id",
"student_token",
"program_code",
"job_title",
"start_date",
"status",
"source_system",
"source_record_id",
"created_at",
"updated_at",
"data_version",
"content_hash"
],
"properties": {
"tenant_id": {
"type": "string",
"pattern": "^TENANT-[A-Z0-9-]{4,64}$",
"description": "学校租户标识,所有存储、任务和查询必须同时校验。"
},
"record_id": {
"type": "string",
"format": "uuid",
"description": "平台全局唯一记录标识。"
},
"internship_id": {
"type": "string",
"minLength": 1,
"maxLength": 128,
"description": "学校租户内的实习业务编号。"
},
"student_token": {
"type": "string",
"pattern": "^STU_[A-Za-z0-9_-]{32,128}$",
"description": "学校侧生成的学生不可逆令牌,禁止传输姓名、身份证和明文学号。"
},
"cohort": {
"type": ["integer", "null"],
"minimum": 2000,
"maximum": 2100,
"description": "学生届次。"
},
"college_code": {
"type": ["string", "null"],
"maxLength": 64,
"description": "学校内部学院代码。"
},
"program_code": {
"type": "string",
"pattern": "^[0-9A-Z]{4,12}$",
"description": "教育部职业教育专业目录代码。"
},
"enterprise_token": {
"type": ["string", "null"],
"pattern": "^ENT_[A-Za-z0-9_-]{24,128}$",
"description": "学校侧或平台企业主数据生成的企业令牌。"
},
"enterprise_name": {
"type": ["string", "null"],
"maxLength": 300,
"description": "用于业务核验的企业名称;导出时按数据范围策略处理。"
},
"enterprise_industry_code": {
"type": ["string", "null"],
"maxLength": 32,
"description": "企业行业代码。"
},
"region_code": {
"type": ["string", "null"],
"pattern": "^[0-9]{6}$",
"description": "实习地区 GB/T 2260 六位代码。"
},
"workplace": {
"type": ["string", "null"],
"maxLength": 500,
"description": "实习地点业务文本。"
},
"job_title": {
"type": "string",
"minLength": 1,
"maxLength": 200,
"description": "实习岗位名称。"
},
"job_code": {
"type": ["string", "null"],
"maxLength": 32,
"description": "映射到统一岗位词典后的岗位代码。"
},
"job_tasks": {
"type": "array",
"maxItems": 100,
"uniqueItems": true,
"items": {
"type": "string",
"maxLength": 500
},
"description": "经学校确认的实习岗位任务。"
},
"start_date": {
"type": "string",
"format": "date",
"description": "实习开始日期。"
},
"end_date": {
"type": ["string", "null"],
"format": "date",
"description": "计划或实际结束日期。"
},
"status": {
"type": "string",
"enum": ["planned", "active", "completed", "terminated", "cancelled"],
"description": "实习业务状态。"
},
"salary_monthly": {
"type": ["number", "null"],
"minimum": 0,
"maximum": 10000000,
"description": "确认后的税前月实习工资,单位元。"
},
"enterprise_mentor_evaluation": {
"type": ["string", "null"],
"maxLength": 4000,
"description": "企业导师业务评价,禁止包含学生敏感身份信息。"
},
"school_evaluation": {
"type": ["string", "null"],
"maxLength": 4000,
"description": "学校指导教师或管理部门评价。"
},
"evidence_reference": {
"type": ["string", "null"],
"maxLength": 256,
"description": "学校系统内的证据引用编号,不传输敏感附件。"
},
"source_system": {
"type": "string",
"minLength": 1,
"maxLength": 128,
"description": "学校来源系统代码。"
},
"source_record_id": {
"type": "string",
"minLength": 1,
"maxLength": 256,
"description": "校内来源系统记录标识。"
},
"source_updated_at": {
"type": ["string", "null"],
"format": "date-time",
"description": "校内来源系统业务更新时间,建议作为增量水位。"
},
"created_at": {
"type": "string",
"format": "date-time",
"description": "平台记录创建时间。"
},
"updated_at": {
"type": "string",
"format": "date-time",
"description": "平台记录更新时间。"
},
"data_version": {
"type": "string",
"pattern": "^SCH-INT-[0-9]{8}(?:-[0-9]{2})?$",
"description": "校内实习数据版本。"
},
"is_deleted": {
"type": "boolean",
"default": false,
"description": "版本化软删除标识。"
},
"content_hash": {
"type": "string",
"pattern": "^[a-f0-9]{64}$",
"description": "去除非业务差异后的记录内容 SHA-256。"
}
},
"allOf": [
{
"if": {
"properties": {
"status": {
"enum": ["active", "completed"]
}
},
"required": ["status"]
},
"then": {
"anyOf": [
{
"properties": {
"enterprise_token": {
"type": "string",
"pattern": "^ENT_[A-Za-z0-9_-]{24,128}$"
}
},
"required": ["enterprise_token"]
},
{
"properties": {
"enterprise_name": {
"type": "string",
"minLength": 1,
"maxLength": 300
}
},
"required": ["enterprise_name"]
}
]
}
}
]
}
+1 -1
View File
@@ -1923,7 +1923,7 @@ textarea:focus {
.contract-tabs {
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
margin-bottom: 13px;
}
+26
View File
@@ -24,6 +24,8 @@ python3 -m http.server 8080
- 改进任务筛选、搜索、创建、阶段进展和报告建议转任务
- 数据源初次导入、增量更新、质量处置、版本管理、授权与审计
- 厂商基础数据包、校级数据、企业数据分类及授权状态筛选
- 覆盖 6 个数据源和 42 份报告的“数据 → 指标/分析主题 → 报告”双向依赖图谱,支持只看缺口
- 报告缺失字段、当前完整率、影响范围、业务索取人、技术接口人和要求完成日期一体化追踪
- 校级管理者、校级技术管理员、学院负责人、专业负责人、就业老师、企业导师、厂商超级管理员七种差异化工作台
- 厂商平台级数据包导入、学校租户、账号权限、备份恢复和审计管理
- 校级、学院、专业、产业专题与数据质量报告中心
@@ -41,6 +43,25 @@ python3 -m http.server 8080
- **校级数据**:来自学校实习、就业、教务等业务系统,数据权属归学校。
- **企业数据**:由合作企业确认提交,按照合作协议限定使用范围。
## 数据与报告依赖图谱
“数据资产”页提供两种追溯方向:
- **从报告看数据**:选择任一校级、学院、专业、产业专题或数据质量报告,查看必需数据、可选增强数据、分析主题、当前完备度和缺口。
- **从数据看报告**:选择任一数据源,查看可形成的分析主题、支撑的报告类型和具体报告数量。
- **只看缺口**:仅保留存在质量或字段问题的依赖关系,快速定位不能稳定支撑报告的数据。
当前样板将 42 份报告映射到 6 个数据源,并以版本化依赖模板区分必需与可选关系。每个缺口展示:
- 缺失或待核验字段;
- 当前记录影响量、完整率和处理状态;
- 业务索取人及所属部门;
- 技术接口人及所属技术部门;
- 要求完成日期和受影响报告数;
- 可直接进入现有质量处置抽屉的操作入口。
依赖元数据用于跨部门补数协同,可以在数据运维角色下查看;报告正文仍按现有角色与数据范围授权,查看依赖关系不自动获得报告阅读权限。静态样板中的依赖、完整率和责任状态均为演示数据,正式系统应将 `ReportDataDependency`、`AnalysisTopic` 和 `DataRequest` 作为租户隔离、版本化、可审计的后端实体。
## 角色视角
- **校级管理者**:全校决策总览、专业对比、校企协同、改进任务、数据资产与报告。
@@ -67,6 +88,7 @@ python3 -m http.server 8080
- 厂商超级管理员在“平台运营中心 → 数据包管理”完成基础数据包导入、Schema 与质量校验、租户授权和版本发布。
- 校级技术管理员登录后默认进入“学校数据运维中心”,管理学校自有数据和企业授权数据。
- 每个数据源的“资产详情”包含业务截止日期、入库日期、下次更新、更新内容、主键、必填字段、格式、Schema、质量阈值、责任人与审计事件。
- “数据与报告双向依赖图谱”同时回答“这份报告还需要什么数据”和“这项数据可以形成哪些报告”,并把缺口直接分派到业务索取人与技术接口人。
- “接收与使用反馈”展示租户、包号、版本、完整 SHA、交付包记录数、独立分析快照、支撑报告/项目/任务、业务可用确认和下一周期反馈。
- 更新采用“暂存 → 校验 → 审批 → 发布”流程。失败数据进入隔离区,不覆盖当前稳定版本。
@@ -93,3 +115,7 @@ python3 -m http.server 8080
- `preview-campus-closed-loop.png`
- `preview-campus-closed-loop-mobile.png`
数据依赖图谱预览:
- `preview-data-report-lineage.png`
+543 -38
View File
@@ -231,6 +231,7 @@ const tasks = [
const sources = [
{
id: "regional-jobs",
name: "区域招聘岗位基础包",
type: "互联网岗位 · 标准数据包",
category: "vendor",
@@ -243,6 +244,7 @@ const sources = [
status: "服务有效",
records: "41,236",
quality: "93.4%",
analysisCompleteness: 98.8,
updated: "2026-03-18",
dataAsOf: "2026-03-17",
nextUpdate: "2026-04-01",
@@ -255,10 +257,13 @@ const sources = [
threshold: "综合质量 ≥ 90;主键唯一率 100%",
changeSummary: "新增 12,430 条,修改 846 条,失效 312 条;新增 AI 应用岗位分类",
steward: "平台数据产品部",
stewardContact: "林岚 · 数据产品经理",
technicalOwner: "平台数据运维组",
technicalContact: "平台数据包运维值班",
action: "检查更新",
},
{
id: "salary-company",
name: "薪资与企业画像基础包",
type: "互联网数据 · 增值数据包",
category: "vendor",
@@ -271,6 +276,7 @@ const sources = [
status: "服务有效",
records: "36,284",
quality: "91.8%",
analysisCompleteness: 100,
updated: "2026-03-18",
dataAsOf: "2026-03-17",
nextUpdate: "2026-04-01",
@@ -283,10 +289,13 @@ const sources = [
threshold: "综合质量 ≥ 90;薪资合法率 ≥ 98%",
changeSummary: "更新 2,186 条薪资样本,补充 326 家企业规模与融资阶段标签",
steward: "平台数据产品部",
stewardContact: "林岚 · 数据产品经理",
technicalOwner: "平台数据运维组",
technicalContact: "平台数据包运维值班",
action: "检查更新",
},
{
id: "internship",
name: "实习管理平台",
type: "校内系统 · API",
category: "school",
@@ -299,6 +308,7 @@ const sources = [
status: "正常",
records: "32,418",
quality: "96.2%",
analysisCompleteness: 100,
updated: "今天 08:12",
dataAsOf: "2026-03-21",
nextUpdate: "明天 08:00",
@@ -311,10 +321,13 @@ const sources = [
threshold: "必填完整率 ≥ 95%;跨表关联率 ≥ 98%",
changeSummary: "新增 186 条,修改 42 条;更新 31 家实习企业岗位信息",
steward: "就业处",
stewardContact: "赵晴 · 实习管理专员",
technicalOwner: "学校信息中心",
technicalContact: "王博 · 数据接口岗",
action: "立即同步",
},
{
id: "employment",
name: "就业管理系统",
type: "校内系统 · 数据库视图",
category: "school",
@@ -327,6 +340,7 @@ const sources = [
status: "字段缺失",
records: "8,926",
quality: "87.6%",
analysisCompleteness: 91.2,
updated: "今天 07:50",
dataAsOf: "2026-03-21",
nextUpdate: "异常处理后重试",
@@ -339,11 +353,14 @@ const sources = [
threshold: "专业代码合法率 100%;必填完整率 ≥ 95%",
changeSummary: "新增 126 条,修改 43 条;86 条薪资记录进入隔离区,未覆盖当前生产版本",
steward: "就业处",
stewardContact: "赵晴 · 就业数据专员",
technicalOwner: "学校信息中心",
technicalContact: "王博 · 数据接口岗",
action: "处理异常",
warning: true,
},
{
id: "curriculum",
name: "课程标准库",
type: "校内文件 · 结构化导入",
category: "school",
@@ -356,6 +373,7 @@ const sources = [
status: "正常",
records: "1,428",
quality: "95.1%",
analysisCompleteness: 100,
updated: "昨天 17:20",
dataAsOf: "2026-03-20",
nextUpdate: "2026-08-25",
@@ -368,10 +386,13 @@ const sources = [
threshold: "课程代码唯一率 100%;技能映射覆盖率 ≥ 80%",
changeSummary: "导入 2026 春季课程快照;新增 38 门课程,修订 126 份课程标准",
steward: "教务处",
stewardContact: "陈璐 · 教务数据专员",
technicalOwner: "学校信息中心",
technicalContact: "王博 · 数据接口岗",
action: "立即同步",
},
{
id: "enterprise-demand",
name: "企业确认需求库",
type: "企业提交 · 问卷/工作台",
category: "enterprise",
@@ -384,6 +405,7 @@ const sources = [
status: "授权有效",
records: "8,350",
quality: "97.3%",
analysisCompleteness: 98.5,
updated: "昨天 20:08",
dataAsOf: "2026-03-21",
nextUpdate: "企业提交后实时入队",
@@ -396,7 +418,9 @@ const sources = [
threshold: "企业身份与授权关联率 100%;必填完整率 ≥ 95%",
changeSummary: "新增 18 家企业确认、54 项岗位需求;12 项过期需求自动停用",
steward: "产教融合中心",
stewardContact: "王健 · 企业合作专员",
technicalOwner: "学校信息中心",
technicalContact: "王博 · 数据接口岗",
action: "查看回执",
},
];
@@ -827,6 +851,7 @@ function syncClosedLoopCollections() {
function saveAndRenderClosedLoop(message = "") {
saveClosedLoopState();
syncClosedLoopCollections();
renderDataReportLineage();
renderClosedLoop();
renderTasks();
renderProjects();
@@ -1502,6 +1527,7 @@ const reports = [
{
id: "salary-2026-03",
type: "industry",
dataProfile: "salary",
typeLabel: "产业专题",
title: "2026届实习就业薪资竞争力分析",
scope: "全校2026届学生",
@@ -1824,6 +1850,150 @@ professionReportCatalog.forEach(([name, college, score, focus], index) => {
});
});
const reportDataProfiles = {
school: {
label: "校级综合分析",
topics: ["区域岗位趋势", "课程技能覆盖", "实习就业成效", "企业需求验证"],
dependencies: [
{ sourceId: "regional-jobs", requirement: "required", purpose: "岗位规模、技能需求与区域趋势" },
{ sourceId: "salary-company", requirement: "optional", purpose: "区域薪资基准与企业画像补充" },
{ sourceId: "internship", requirement: "required", purpose: "学生实习岗位与过程成效" },
{ sourceId: "employment", requirement: "required", purpose: "毕业去向、岗位与就业薪资" },
{ sourceId: "curriculum", requirement: "required", purpose: "专业课程与技能培养供给" },
{ sourceId: "enterprise-demand", requirement: "required", purpose: "合作企业正式确认的人才需求" },
],
},
college: {
label: "学院专业建设分析",
topics: ["专业结构对比", "共享能力分析", "培养成效验证", "企业协同机会"],
dependencies: [
{ sourceId: "regional-jobs", requirement: "required", purpose: "学院相关岗位与技能趋势" },
{ sourceId: "salary-company", requirement: "optional", purpose: "专业群薪资和企业结构参考" },
{ sourceId: "internship", requirement: "optional", purpose: "学院实习岗位与实践反馈" },
{ sourceId: "employment", requirement: "optional", purpose: "学院毕业去向与岗位匹配验证" },
{ sourceId: "curriculum", requirement: "required", purpose: "跨专业课程与共享技能供给" },
{ sourceId: "enterprise-demand", requirement: "required", purpose: "学院合作企业需求与项目机会" },
],
},
profession: {
label: "专业人才需求分析",
topics: ["岗位技能需求", "课程能力映射", "学生达成验证", "企业需求复核"],
dependencies: [
{ sourceId: "regional-jobs", requirement: "required", purpose: "对口岗位、技能频次与需求变化" },
{ sourceId: "salary-company", requirement: "optional", purpose: "区域同岗位薪资竞争力" },
{ sourceId: "internship", requirement: "optional", purpose: "学生实践岗位与企业评价" },
{ sourceId: "employment", requirement: "optional", purpose: "毕业去向和岗位匹配验证" },
{ sourceId: "curriculum", requirement: "required", purpose: "课程标准、学时与技能覆盖" },
{ sourceId: "enterprise-demand", requirement: "required", purpose: "真实企业需求与合作意向确认" },
],
},
industry: {
label: "产业人才缺口分析",
topics: ["产业岗位趋势", "岗位技能归一", "培养供给对照", "企业确认校验"],
dependencies: [
{ sourceId: "regional-jobs", requirement: "required", purpose: "产业岗位规模、增速与技能信号" },
{ sourceId: "salary-company", requirement: "optional", purpose: "重点产业薪资与企业结构" },
{ sourceId: "curriculum", requirement: "required", purpose: "全校专业与课程技能供给" },
{ sourceId: "enterprise-demand", requirement: "required", purpose: "合作企业需求交叉验证" },
],
},
salary: {
label: "实习就业薪资分析",
topics: ["区域薪资基准", "实习薪资分布", "就业薪资对比", "专业竞争力"],
dependencies: [
{ sourceId: "regional-jobs", requirement: "optional", purpose: "区域岗位热度与样本辅助筛选" },
{ sourceId: "salary-company", requirement: "required", purpose: "区域同岗位薪资基准" },
{ sourceId: "internship", requirement: "required", purpose: "学生实习薪资与岗位信息" },
{ sourceId: "employment", requirement: "required", purpose: "毕业生就业薪资与去向" },
],
},
quality: {
label: "数据质量与口径分析",
topics: ["完整性与准确性", "版本与时效", "授权与权属", "加工血缘"],
dependencies: [
{ sourceId: "regional-jobs", requirement: "required", purpose: "岗位数据质量与归一覆盖" },
{ sourceId: "salary-company", requirement: "required", purpose: "薪资和企业画像质量" },
{ sourceId: "internship", requirement: "required", purpose: "实习数据完整性与时效" },
{ sourceId: "employment", requirement: "required", purpose: "就业数据完整性与异常记录" },
{ sourceId: "curriculum", requirement: "required", purpose: "课程数据版本与技能映射" },
{ sourceId: "enterprise-demand", requirement: "required", purpose: "企业身份、授权与需求质量" },
],
},
};
const dataGapCatalog = [
{
id: "job-normalization",
sourceId: "regional-jobs",
level: "attention",
levelLabel: "待复核",
title: "新增岗位缺少标准岗位代码与技能映射",
drawerTitle: "新增岗位名称待归一",
drawerLevel: "需要复核",
amount: "218 个岗位名称 · 影响 1,426 条记录",
affected: "218 个岗位别名 · 影响 1,426 条招聘记录",
fields: ["standard_position_code", "normalized_job_name", "skill_tags"],
affectedProfileKeys: ["school", "college", "profession", "industry", "quality"],
reason: "新增“具身智能应用工程师”等岗位未映射到当前岗位分类词典 V3.2。",
action: "优先审核需求量前 50 的岗位名称,确认标准岗位、技能标签及别名规则。",
status: "平台侧复核中",
due: "2026-03-24",
request: "请平台数据产品部确认标准岗位及技能标签,由平台数据运维组发布别名规则增量。",
},
{
id: "salary-missing",
sourceId: "employment",
level: "blocking",
levelLabel: "需补数",
title: "就业薪资与缺失原因未完整提供",
drawerTitle: "就业系统薪资字段缺失",
drawerLevel: "阻断发布",
amount: "86 条记录 · 当前字段完整率 91.2%",
affected: "86 条记录 · 电子商务专业 41 条、现代服务类专业 45 条",
fields: ["employment_salary", "salary_period", "missing_reason"],
affectedReportIds: ["school-2026-03", "salary-2026-03", "quality-2026-03", "ecommerce-2026-03"],
reason: "employment_salary 为空,未达到必填完整率 95% 的发布阈值。",
action: "由就业处补齐薪资或填写缺失原因,修复文件按 student_id + cohort 回写后重新校验。",
status: "待就业处补充",
due: "2026-03-25",
request: "请就业处补齐薪资或登记缺失原因,由学校信息中心回写只读视图并重新执行质量校验。",
},
{
id: "enterprise-duplicate",
sourceId: "enterprise-demand",
level: "attention",
levelLabel: "待核验",
title: "企业主标识存在疑似重复",
drawerTitle: "疑似重复企业记录",
drawerLevel: "一般事项",
amount: "12 家企业 · 涉及 37 项确认需求",
affected: "12 家企业 · 涉及 37 项岗位需求",
fields: ["enterprise_credit_code", "master_enterprise_id"],
affectedProfileKeys: ["school", "college", "profession", "industry", "quality"],
reason: "企业简称不同,但统一社会信用代码或工商地址高度相似。",
action: "由产教融合中心确认主企业记录,合并需求与授权回执并保留审计映射。",
status: "待产教融合中心确认",
due: "2026-03-26",
request: "请产教融合中心确认企业主记录,由学校信息中心合并需求与授权回执并保留审计映射。",
},
];
const sourceAnalysisTopics = {
"regional-jobs": ["岗位需求趋势", "岗位技能归一", "产业增长信号"],
"salary-company": ["区域薪资基准", "企业规模画像", "岗位竞争力"],
internship: ["实习岗位分布", "实践过程成效", "企业评价"],
employment: ["毕业去向", "就业薪资", "岗位匹配成效"],
curriculum: ["课程技能覆盖", "专业培养供给", "课程版本"],
"enterprise-demand": ["企业需求确认", "校企合作机会", "授权有效性"],
};
const dataLineageState = {
mode: "report",
selectedReportId: "school-2026-03",
selectedSourceId: "employment",
gapsOnly: false,
};
const iconPaths = {
course: '<path d="M4 5h16v14H4V5Zm4 0V3h8v2M8 10h8m-8 4h5"/>',
lab: '<path d="M9 3h6m-5 0v6l-5 9a2 2 0 0 0 2 3h10a2 2 0 0 0 2-3l-5-9V3M8 15h8"/>',
@@ -1983,6 +2153,7 @@ function applyRole(role, options = {}) {
]);
}
renderDataReportLineage();
renderClosedLoop();
if (document.getElementById("professionSelect")) {
renderProfessionProfile(document.getElementById("professionSelect").value);
@@ -2522,6 +2693,359 @@ function renderTasks(filter = "all", search = "") {
}
}
function reportDataProfileKey(report) {
return report.dataProfile || report.type;
}
function reportDataProfile(report) {
return reportDataProfiles[reportDataProfileKey(report)] || reportDataProfiles.profession;
}
function sourceById(sourceId) {
return sources.find((source) => source.id === sourceId);
}
function gapsForSource(sourceId) {
return dataGapCatalog.filter((gap) => gap.sourceId === sourceId);
}
function dataGapById(gapId) {
return dataGapCatalog.find((gap) => gap.id === gapId);
}
function dependencyForReport(report, sourceId) {
return reportDataProfile(report).dependencies.find((dependency) => dependency.sourceId === sourceId);
}
function reportsUsingSource(sourceId) {
return reports.filter((report) => Boolean(dependencyForReport(report, sourceId)));
}
function gapAffectsReport(gap, report) {
if (!dependencyForReport(report, gap.sourceId)) return false;
if (gap.affectedReportIds) return gap.affectedReportIds.includes(report.id);
if (report.periodKey !== "current") return false;
return gap.affectedProfileKeys?.includes(reportDataProfileKey(report)) ?? false;
}
function reportsAffectedByGap(gap) {
return reports.filter((report) => gapAffectsReport(gap, report));
}
function reportReadiness(report) {
const requiredDependencies = reportDataProfile(report).dependencies.filter(
(dependency) => dependency.requirement === "required",
);
if (!requiredDependencies.length) return 100;
return (
requiredDependencies.reduce(
(total, dependency) => total + sourceById(dependency.sourceId).analysisCompleteness,
0,
) /
requiredDependencies.length
);
}
function reportGaps(report) {
return dataGapCatalog.filter((gap) => gapAffectsReport(gap, report));
}
function reportsGroupedByType(reportItems) {
return reportItems.reduce((groups, report) => {
const group = groups.get(report.type) || {
type: report.type,
label: report.typeLabel,
reports: [],
};
group.reports.push(report);
groups.set(report.type, group);
return groups;
}, new Map());
}
function readableReportIdSet() {
return new Set(reportsForCurrentRole().map((report) => report.id));
}
function lineageEmptyNode(message) {
return `<div class="lineage-empty-node">${svgIcon("database")}<strong>${message}</strong><small>关闭“只看缺口”可查看完整依赖。</small></div>`;
}
function renderLineageSourceNode(source, dependency, selected = false, relevantGaps = gapsForSource(source.id)) {
const gaps = relevantGaps.filter((gap) => gap.sourceId === source.id);
const hasGap = gaps.length > 0;
const requirement = dependency?.requirement || "required";
const completeness = source.analysisCompleteness;
return `
<button class="lineage-node lineage-source-node ${hasGap ? "has-gap" : ""} ${selected ? "selected" : ""}" data-source-detail="${source.name}">
<span class="lineage-node-top">
<em class="${requirement}">${requirement === "required" ? "必需数据" : "可选增强"}</em>
<i class="${hasGap ? "gap" : "ready"}">${hasGap ? gaps[0].levelLabel : "可用"}</i>
</span>
<strong>${source.name}</strong>
<small>${dependency?.purpose || source.type}</small>
<span class="lineage-node-meta">
<span><b>${completeness.toFixed(1)}%</b> 完整</span>
<span>${source.steward}</span>
</span>
${hasGap ? `<span class="lineage-node-gap">${gaps.map((gap) => gap.fields.slice(0, 2).join("、")).join(";")}</span>` : ""}
</button>
`;
}
function renderLineageTopicNode(topic, index) {
return `
<div class="lineage-topic-node">
<span>${String(index + 1).padStart(2, "0")}</span>
<strong>${topic}</strong>
<small>统一口径计算 · 保留证据定位</small>
</div>
`;
}
function renderLineageReportNode(report, readableIds = readableReportIdSet()) {
const gaps = reportGaps(report);
const readiness = reportReadiness(report);
const canRead = readableIds.has(report.id);
return `
<button class="lineage-node lineage-report-node ${gaps.length ? "has-gap" : ""}" data-lineage-report="${report.id}">
<span class="lineage-node-top">
<em>${report.typeLabel}</em>
<i class="${gaps.length ? "gap" : "ready"}">${gaps.length ? `${gaps.length}项缺口` : "数据齐备"}</i>
</span>
<strong>${report.title}</strong>
<small>${report.scope}</small>
<span class="lineage-node-meta">
<span><b>${readiness.toFixed(1)}%</b> 完备</span>
<span>${canRead ? report.version : "仅依赖元数据"}</span>
</span>
</button>
`;
}
function renderLineageReportGroup(group, readableIds) {
const affectedCount = group.reports.filter((report) => reportGaps(report).length).length;
return `
<details class="lineage-report-group" ${group.reports.length <= 5 ? "open" : ""}>
<summary>
<span><strong>${group.label}</strong><small>${affectedCount ? `${affectedCount} 份存在数据缺口` : "当前数据关系可用"}</small></span>
<em>${group.reports.length} 份</em>
</summary>
<div class="lineage-report-group-list">
${group.reports.map((report) => renderLineageReportNode(report, readableIds)).join("")}
</div>
</details>
`;
}
function renderLineageStage(title, countText, className, content) {
return `
<section class="lineage-stage ${className}">
<div class="lineage-stage-head"><span>${title}</span><strong>${countText}</strong></div>
<div class="lineage-stage-list">${content}</div>
</section>
`;
}
function lineageConnector(label) {
return `
<div class="lineage-connector" aria-hidden="true">
<span>${label}</span>
<i><b></b></i>
</div>
`;
}
function renderReportLineageCanvas(report) {
const profile = reportDataProfile(report);
const gaps = reportGaps(report);
const readableIds = readableReportIdSet();
const gapSourceIds = new Set(gaps.map((gap) => gap.sourceId));
const visibleDependencies = dataLineageState.gapsOnly
? profile.dependencies.filter((dependency) => gapSourceIds.has(dependency.sourceId))
: profile.dependencies;
const sourceNodes =
visibleDependencies
.map((dependency) => renderLineageSourceNode(sourceById(dependency.sourceId), dependency, false, gaps))
.join("") || lineageEmptyNode("当前报告没有数据缺口");
const topicNodes = profile.topics.map(renderLineageTopicNode).join("");
return `
<div class="lineage-stage-grid report-direction">
${renderLineageStage("数据资产", `${visibleDependencies.length} / ${profile.dependencies.length} 项`, "sources", sourceNodes)}
${lineageConnector("形成指标")}
${renderLineageStage("指标 / 分析主题", `${profile.topics.length} 类`, "topics", topicNodes)}
${lineageConnector("生成报告")}
${renderLineageStage("目标报告", "1 份", "reports", renderLineageReportNode(report, readableIds))}
</div>
`;
}
function renderSourceLineageCanvas(source) {
const sourceReports = reportsUsingSource(source.id);
const affectedReportIds = new Set(
gapsForSource(source.id).flatMap((gap) => reportsAffectedByGap(gap).map((report) => report.id)),
);
const visibleReports = dataLineageState.gapsOnly
? sourceReports.filter((report) => affectedReportIds.has(report.id))
: sourceReports;
const reportGroups = [...reportsGroupedByType(visibleReports).values()];
const readableIds = readableReportIdSet();
const reportNodes =
reportGroups
.map((group) => renderLineageReportGroup(group, readableIds))
.join("") || lineageEmptyNode("当前数据来源没有待处理的报告关系");
const topics = sourceAnalysisTopics[source.id] || ["基础指标", "专题分析", "质量校验"];
return `
<div class="lineage-stage-grid source-direction">
${renderLineageStage("选中数据", "1 项", "sources", renderLineageSourceNode(source, null, true))}
${lineageConnector("支撑分析")}
${renderLineageStage("可形成的分析", `${topics.length} 类`, "topics", topics.map(renderLineageTopicNode).join(""))}
${lineageConnector("支撑报告")}
${renderLineageStage("可形成的报告", `${visibleReports.length} 份`, "reports", reportNodes)}
</div>
`;
}
function lineageSummaryCard(label, value, note, className = "") {
return `<article class="${className}"><span>${label}</span><strong>${value}</strong><small>${note}</small></article>`;
}
function renderLineageSummary(report, source) {
const summary = document.getElementById("dataLineageSummary");
if (dataLineageState.mode === "report") {
const profile = reportDataProfile(report);
const requiredCount = profile.dependencies.filter((dependency) => dependency.requirement === "required").length;
const gaps = reportGaps(report);
summary.innerHTML = [
lineageSummaryCard("当前报告", report.typeLabel, report.version),
lineageSummaryCard("依赖数据", `${requiredCount} 必需 · ${profile.dependencies.length - requiredCount} 可选`, "依赖关系已登记"),
lineageSummaryCard("数据完备度", `${reportReadiness(report).toFixed(1)}%`, gaps.length ? "使用稳定版本,可继续分析" : "达到生成条件"),
lineageSummaryCard("待补数据", `${gaps.length} 项`, gaps.length ? "已定位责任人与时限" : "当前无数据缺口", gaps.length ? "warning" : "ready"),
].join("");
return;
}
const sourceReports = reportsUsingSource(source.id);
const gaps = gapsForSource(source.id);
summary.innerHTML = [
lineageSummaryCard("当前数据", source.categoryLabel, source.version),
lineageSummaryCard("支撑报告", `${sourceReports.length} 份`, `${reportsGroupedByType(sourceReports).size} 类报告`),
lineageSummaryCard("当前质量", source.quality, `${source.records} 条有效记录`),
lineageSummaryCard("数据缺口", `${gaps.length} 项`, gaps.length ? "已定位责任人与时限" : "当前无数据缺口", gaps.length ? "warning" : "ready"),
].join("");
}
function renderLineageGapCard(gap, selectedReport) {
const source = sourceById(gap.sourceId);
const impactCount = selectedReport ? 1 : reportsAffectedByGap(gap).length;
return `
<article class="lineage-gap-card ${gap.level}">
<div class="lineage-gap-card-head">
<span><i></i>${gap.levelLabel}</span>
<em>${gap.status}</em>
</div>
<strong>${gap.title}</strong>
<small>${source.name} · ${gap.amount}</small>
<div class="lineage-gap-fields">${gap.fields.map((field) => `<span>${field}</span>`).join("")}</div>
<p>${gap.request}</p>
<dl>
<div><dt>业务索取人</dt><dd>${source.stewardContact}</dd></div>
<div><dt>技术接口人</dt><dd>${source.technicalContact}</dd></div>
<div><dt>要求完成</dt><dd>${gap.due}</dd></div>
<div><dt>影响范围</dt><dd>${selectedReport ? "当前报告" : `${impactCount} 份报告`}</dd></div>
</dl>
<button data-lineage-gap-action="${gap.id}">进入补数与质量处置</button>
</article>
`;
}
function renderLineageGaps(report, source) {
const gaps = dataLineageState.mode === "report" ? reportGaps(report) : gapsForSource(source.id);
document.getElementById("dataLineageGapCount").textContent = `${gaps.length} 项待处理`;
document.getElementById("dataLineageGapList").innerHTML = gaps.length
? gaps.map((gap) => renderLineageGapCard(gap, dataLineageState.mode === "report" ? report : null)).join("")
: `
<div class="lineage-gap-empty">
${svgIcon("database")}
<span><strong>当前关系没有待补数据</strong><small>数据版本、质量和授权均达到报告生成要求。</small></span>
</div>
`;
}
function renderDataReportLineage() {
const selector = document.getElementById("dataLineageSelector");
if (!selector) return;
const mode = dataLineageState.mode;
const readableReportIds = new Set(reportsForCurrentRole().map((report) => report.id));
if (!reports.some((report) => report.id === dataLineageState.selectedReportId)) {
dataLineageState.selectedReportId = "school-2026-03";
}
if (!sources.some((source) => source.id === dataLineageState.selectedSourceId)) {
dataLineageState.selectedSourceId = sources[0].id;
}
const report = reports.find((item) => item.id === dataLineageState.selectedReportId) || reports[0];
const source = sourceById(dataLineageState.selectedSourceId) || sources[0];
document.querySelectorAll("[data-lineage-mode]").forEach((button) => {
const selected = button.dataset.lineageMode === mode;
button.classList.toggle("active", selected);
button.setAttribute("aria-pressed", String(selected));
});
document.getElementById("dataLineageSelectorLabel").textContent = mode === "report" ? "选择报告" : "选择数据";
selector.setAttribute("aria-label", mode === "report" ? "选择要追溯的报告" : "选择要反查的数据");
selector.innerHTML =
mode === "report"
? reports
.map((item) => {
const canRead = readableReportIds.has(item.id);
return `<option value="${item.id}">${item.typeLabel} · ${item.title}${canRead ? "" : " · 仅依赖元数据"}</option>`;
})
.join("")
: sources
.map((item) => `<option value="${item.id}">${item.categoryLabel} · ${item.name}</option>`)
.join("");
selector.value = mode === "report" ? report.id : source.id;
document.getElementById("dataLineageGapToggle").checked = dataLineageState.gapsOnly;
document.getElementById("dataLineageCanvas").innerHTML =
mode === "report" ? renderReportLineageCanvas(report) : renderSourceLineageCanvas(source);
renderLineageSummary(report, source);
renderLineageGaps(report, source);
}
function setupDataReportLineage() {
const lineage = document.getElementById("dataReportLineage");
lineage.addEventListener("click", (event) => {
const modeButton = event.target.closest("[data-lineage-mode]");
if (modeButton) {
dataLineageState.mode = modeButton.dataset.lineageMode;
renderDataReportLineage();
return;
}
const reportButton = event.target.closest("[data-lineage-report]");
if (reportButton) {
const reportId = reportButton.dataset.lineageReport;
if (!reportsForCurrentRole().some((report) => report.id === reportId)) {
showToast("依赖元数据可见,但当前角色没有该报告正文的阅读权限。", "权限范围提示");
return;
}
openReportReader(reportId);
return;
}
const gapButton = event.target.closest("[data-lineage-gap-action]");
if (gapButton) openDataOpsDrawer("issue", gapButton.dataset.lineageGapAction);
});
document.getElementById("dataLineageSelector").addEventListener("change", (event) => {
if (dataLineageState.mode === "report") dataLineageState.selectedReportId = event.target.value;
else dataLineageState.selectedSourceId = event.target.value;
renderDataReportLineage();
});
document.getElementById("dataLineageGapToggle").addEventListener("change", (event) => {
dataLineageState.gapsOnly = event.target.checked;
renderDataReportLineage();
});
}
const dataOpsState = {
context: "source",
sourceName: sources[0].name,
@@ -2529,33 +3053,6 @@ const dataOpsState = {
issue: null,
};
const dataIssueCatalog = {
"salary-missing": {
title: "就业系统薪资字段缺失",
level: "阻断发布",
sourceName: "就业管理系统",
affected: "86 条记录 · 电子商务专业 41 条、现代服务类专业 45 条",
reason: "employment_salary 为空,未达到必填完整率 95% 的发布阈值。",
action: "由就业处补齐薪资或填写缺失原因,修复文件按 student_id + cohort 回写后重新校验。",
},
"job-normalization": {
title: "新增岗位名称待归一",
level: "需要复核",
sourceName: "区域招聘岗位基础包",
affected: "218 个岗位别名 · 影响 1,426 条招聘记录",
reason: "新增“具身智能应用工程师”等岗位未映射到当前岗位分类词典 V3.2。",
action: "优先审核需求量前 50 的岗位名称,确认标准岗位、技能标签及别名规则。",
},
"enterprise-duplicate": {
title: "疑似重复企业记录",
level: "一般事项",
sourceName: "企业确认需求库",
affected: "12 家企业 · 涉及 37 项岗位需求",
reason: "企业简称不同,但统一社会信用代码或工商地址高度相似。",
action: "由产教融合中心确认主企业记录,合并需求与授权回执并保留审计映射。",
},
};
function currentDataSource() {
return sources.find((source) => source.name === dataOpsState.sourceName) || sources[0];
}
@@ -2582,8 +3079,8 @@ function renderDataOpsOverview(source) {
<h3>更新与责任</h3>
<dl>
<div><dt>数据类别</dt><dd>${source.categoryLabel} · ${source.commercial}</dd></div>
<div><dt>业务责任人</dt><dd>${source.steward}</dd></div>
<div><dt>技术责任人</dt><dd>${source.technicalOwner}</dd></div>
<div><dt>业务责任人</dt><dd>${source.stewardContact} · ${source.steward}</dd></div>
<div><dt>技术责任人</dt><dd>${source.technicalContact} · ${source.technicalOwner}</dd></div>
<div><dt>更新模式</dt><dd>${source.updateMode}</dd></div>
<div><dt>当前状态</dt><dd><span class="data-ops-state ${source.warning ? "warning" : ""}">${source.status}</span></dd></div>
</dl>
@@ -2674,7 +3171,7 @@ function renderDataOpsAudit(source) {
return `
<div class="data-authorization-card">
<span>权属与授权</span><strong>${source.rights}</strong><p>${authorization}</p>
<div><span>业务责任人<strong>${source.steward}</strong></span><span>技术责任人<strong>${source.technicalOwner}</strong></span><span>授权状态<strong>${source.status}</strong></span></div>
<div><span>业务责任人<strong>${source.stewardContact} · ${source.steward}</strong></span><span>技术责任人<strong>${source.technicalContact} · ${source.technicalOwner}</strong></span><span>授权状态<strong>${source.status}</strong></span></div>
</div>
<article class="data-ops-card">
<h3>最近审计事件</h3>
@@ -2721,17 +3218,23 @@ function renderDataOpsUsage() {
</article>`;
}
function renderDataIssue(issueKey) {
const issue = dataIssueCatalog[issueKey];
function renderDataIssue(issueId) {
const issue = dataGapById(issueId);
const source = currentDataSource();
const impactCount = reportsAffectedByGap(issue).length;
return `
<div class="data-issue-hero">
<span>${issue.level}</span><h3>${issue.title}</h3><p>${issue.affected}</p>
<span>${issue.drawerLevel}</span><h3>${issue.drawerTitle}</h3><p>${issue.affected}</p>
</div>
<article class="data-ops-card"><h3>规则命中原因</h3><p>${issue.reason}</p></article>
<article class="data-ops-card"><h3>建议处置</h3><p>${issue.action}</p></article>
<article class="data-ops-card">
<h3>受影响分析</h3>
<div class="data-impact-grid"><span>当前生产版本<strong>未覆盖</strong></span><span>分析快照<strong>沿用稳定版本</strong></span><span>责任部门<strong>${currentDataSource().steward}</strong></span></div>
<h3>受影响分析与补数责任</h3>
<div class="data-impact-grid"><span>当前生产版本<strong>未覆盖</strong></span><span>影响报告<strong>${impactCount} 份</strong></span><span>分析快照<strong>沿用稳定版本</strong></span></div>
<dl class="data-issue-owner-list">
<div><dt>业务索取人</dt><dd>${source.stewardContact} · ${source.steward}</dd></div>
<div><dt>技术接口人</dt><dd>${source.technicalContact} · ${source.technicalOwner}</dd></div>
</dl>
</article>
`;
}
@@ -2841,11 +3344,11 @@ function updateDataOpsDrawer() {
}
if (dataOpsState.context === "issue") {
const issue = dataIssueCatalog[dataOpsState.issue];
const issueSource = sources.find((item) => item.name === issue.sourceName);
const issue = dataGapById(dataOpsState.issue);
const issueSource = sourceById(issue.sourceId);
if (issueSource) dataOpsState.sourceName = issueSource.name;
document.getElementById("dataOpsKicker").textContent = "DATA QUALITY WORKBENCH";
document.getElementById("dataOpsTitle").textContent = issue.title;
document.getElementById("dataOpsTitle").textContent = issue.drawerTitle;
document.getElementById("dataOpsDescription").textContent = "查看受影响数据、规则命中原因和可审计的处理流程。";
content.innerHTML = renderDataIssue(dataOpsState.issue);
footer.innerHTML = `<button class="button secondary" data-data-ops-result="错误明细文件已准备,可交由责任部门修复">下载错误明细</button><button class="button primary" data-data-ops-result="已创建质量整改任务,并通知责任部门">创建整改任务</button>`;
@@ -4707,6 +5210,7 @@ function init() {
renderPartners();
renderTasks();
renderSources();
renderDataReportLineage();
setupGenericInteractions();
setupRoleExperience();
setupPlatformActions();
@@ -4716,6 +5220,7 @@ function init() {
setupForms();
setupFilters();
setupSourceFilters();
setupDataReportLineage();
setupDataOperations();
setupWorkspaceDetails();
setupProductExperience();
+49 -1
View File
@@ -870,6 +870,54 @@
<button id="dataAuthorizationButton">查看授权说明</button>
</div>
<article class="panel data-lineage-panel" id="dataReportLineage">
<div class="panel-header data-lineage-header">
<div>
<span class="panel-kicker">DATA → ANALYSIS → REPORT</span>
<h2>数据与报告双向依赖图谱</h2>
<p>从报告反查必需数据,也可从数据查看能形成的报告;缺口直接定位业务索取人和技术接口人。</p>
</div>
<span class="lineage-snapshot-badge"><i></i>分析快照 V2026.03</span>
</div>
<div class="data-lineage-toolbar">
<div class="lineage-mode-switch" id="dataLineageModeSwitch" role="group" aria-label="选择图谱查看方向">
<button class="active" data-lineage-mode="report" aria-pressed="true">从报告看数据</button>
<button data-lineage-mode="source" aria-pressed="false">从数据看报告</button>
</div>
<label class="lineage-selector">
<span id="dataLineageSelectorLabel">选择报告</span>
<select id="dataLineageSelector" aria-label="选择要追溯的报告"></select>
</label>
<label class="lineage-gap-toggle">
<input type="checkbox" id="dataLineageGapToggle">
<span><i></i>只看缺口</span>
</label>
</div>
<div class="lineage-summary-grid" id="dataLineageSummary" aria-live="polite"></div>
<div class="lineage-legend" aria-label="图谱图例">
<span><i class="required"></i>必需数据</span>
<span><i class="optional"></i>可选增强</span>
<span><i class="gap"></i>存在缺口</span>
<small>点击数据节点查看资产要求,点击报告节点进入在线报告。</small>
</div>
<div class="data-lineage-canvas" id="dataLineageCanvas"></div>
<div class="lineage-gap-section">
<div class="lineage-gap-heading">
<div>
<span>DATA REQUEST DESK</span>
<h3>缺口与补数责任</h3>
</div>
<strong id="dataLineageGapCount">0 项待处理</strong>
</div>
<div class="lineage-gap-list" id="dataLineageGapList"></div>
</div>
</article>
<article class="panel source-panel">
<div class="panel-header">
<div>
@@ -918,7 +966,7 @@
<span class="panel-kicker">质量事项</span>
<h2>待处理问题</h2>
</div>
<span class="panel-count amber-count">4 项</span>
<span class="panel-count amber-count">3 项</span>
</div>
<div class="issue-list">
<button data-data-issue="salary-missing">
Binary file not shown.

After

Width:  |  Height:  |  Size: 330 KiB

+924
View File
@@ -3085,6 +3085,840 @@ svg {
font-weight: 600;
}
.data-lineage-panel {
margin-bottom: 14px;
overflow: hidden;
background:
radial-gradient(circle at 98% 1%, rgba(198, 230, 111, 0.08), transparent 21%),
white;
}
.data-lineage-header {
align-items: flex-start;
}
.data-lineage-header h2 {
margin-bottom: 7px;
}
.data-lineage-header p {
max-width: 720px;
margin: 0;
color: var(--muted);
line-height: 1.65;
}
.lineage-snapshot-badge {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
gap: 7px;
padding: 8px 11px;
color: var(--green);
background: var(--green-soft);
border: 1px solid #d2e8db;
border-radius: 9px;
font-weight: 700;
}
.lineage-snapshot-badge i {
width: 7px;
height: 7px;
background: var(--green);
border-radius: 50%;
box-shadow: 0 0 0 4px rgba(35, 135, 101, 0.11);
}
.data-lineage-toolbar {
display: grid;
grid-template-columns: auto minmax(280px, 1fr) auto;
align-items: end;
gap: 12px;
margin-top: 18px;
padding: 12px;
background: #f4f7f4;
border: 1px solid #e5ebe6;
border-radius: 12px;
}
.lineage-mode-switch {
display: flex;
gap: 4px;
padding: 4px;
background: #e8ede9;
border-radius: 9px;
}
.lineage-mode-switch button {
min-height: 38px;
padding: 0 13px;
color: var(--muted);
background: transparent;
border-radius: 7px;
font-weight: 700;
white-space: nowrap;
}
.lineage-mode-switch button.active {
color: white;
background: var(--green);
box-shadow: 0 4px 12px rgba(23, 107, 79, 0.18);
}
.lineage-selector {
display: grid;
min-width: 0;
gap: 5px;
color: var(--muted);
font-weight: 600;
}
.lineage-selector select {
width: 100%;
min-height: 38px;
padding: 0 34px 0 11px;
overflow: hidden;
color: var(--ink);
background: white;
border: 1px solid #dfe6e1;
border-radius: 8px;
text-overflow: ellipsis;
}
.lineage-gap-toggle {
display: flex;
min-height: 38px;
align-items: center;
cursor: pointer;
}
.lineage-gap-toggle input {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
opacity: 0;
}
.lineage-gap-toggle > span {
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--muted);
font-weight: 700;
white-space: nowrap;
}
.lineage-gap-toggle > span i {
position: relative;
width: 38px;
height: 22px;
background: #cbd4ce;
border-radius: 12px;
transition: background 0.18s ease;
}
.lineage-gap-toggle > span i::after {
position: absolute;
top: 3px;
left: 3px;
width: 16px;
height: 16px;
background: white;
border-radius: 50%;
box-shadow: 0 2px 5px rgba(21, 54, 42, 0.2);
content: "";
transition: transform 0.18s ease;
}
.lineage-gap-toggle input:checked + span {
color: #9a6515;
}
.lineage-gap-toggle input:checked + span i {
background: var(--amber);
}
.lineage-gap-toggle input:checked + span i::after {
transform: translateX(16px);
}
.lineage-gap-toggle input:focus-visible + span i {
outline: 3px solid rgba(35, 135, 101, 0.2);
outline-offset: 2px;
}
.lineage-summary-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 9px;
margin-top: 12px;
}
.lineage-summary-grid article {
min-width: 0;
padding: 13px 14px;
background: #fafbf9;
border: 1px solid var(--line-soft);
border-radius: 10px;
}
.lineage-summary-grid article.warning {
background: #fff9ed;
border-color: #efdcae;
}
.lineage-summary-grid article.ready {
background: #f1f8f4;
border-color: #d8e9de;
}
.lineage-summary-grid span,
.lineage-summary-grid strong,
.lineage-summary-grid small {
display: block;
}
.lineage-summary-grid span {
color: var(--muted);
}
.lineage-summary-grid strong {
margin: 7px 0 4px;
color: var(--ink);
font-size: 16px;
}
.lineage-summary-grid article.warning strong {
color: #9a6515;
}
.lineage-summary-grid article.ready strong {
color: var(--green);
}
.lineage-summary-grid small {
color: var(--muted);
line-height: 1.45;
}
.lineage-legend {
display: flex;
align-items: center;
gap: 15px;
margin: 18px 0 9px;
color: var(--muted);
}
.lineage-legend > span {
display: inline-flex;
align-items: center;
gap: 6px;
white-space: nowrap;
}
.lineage-legend i {
width: 11px;
height: 11px;
border: 2px solid var(--green);
border-radius: 3px;
}
.lineage-legend i.optional {
border-color: #6d88a1;
border-style: dashed;
}
.lineage-legend i.gap {
background: var(--amber-soft);
border-color: var(--amber);
}
.lineage-legend small {
margin-left: auto;
text-align: right;
}
.data-lineage-canvas {
padding: 15px;
background:
linear-gradient(rgba(34, 105, 79, 0.035) 1px, transparent 1px),
linear-gradient(90deg, rgba(34, 105, 79, 0.035) 1px, transparent 1px),
#f7f9f7;
background-size: 24px 24px;
border: 1px solid #e1e8e3;
border-radius: 14px;
}
.lineage-stage-grid {
display: grid;
grid-template-columns: minmax(250px, 1.08fr) 48px minmax(210px, 0.82fr) 48px minmax(250px, 1fr);
align-items: start;
gap: 8px;
}
.lineage-stage {
min-width: 0;
overflow: hidden;
background: rgba(255, 255, 255, 0.94);
border: 1px solid #dfe7e1;
border-radius: 12px;
box-shadow: 0 8px 24px rgba(25, 58, 45, 0.06);
}
.lineage-stage-head {
display: flex;
min-height: 45px;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 10px 12px;
color: var(--green);
background: #edf6f1;
border-bottom: 1px solid #dce9e1;
}
.lineage-stage.topics .lineage-stage-head {
color: #376991;
background: #eef4f9;
border-color: #dae6ef;
}
.lineage-stage.reports .lineage-stage-head {
color: #8b621f;
background: #fff8ea;
border-color: #eee0bd;
}
.lineage-stage-head span {
font-weight: 800;
}
.lineage-stage-head strong {
font-weight: 700;
}
.lineage-stage-list {
display: grid;
gap: 8px;
padding: 9px;
}
.lineage-node {
display: block;
width: 100%;
min-width: 0;
padding: 11px;
color: var(--ink);
background: white;
border: 1px solid #dfe7e1;
border-radius: 10px;
text-align: left;
transition:
transform 0.18s ease,
border-color 0.18s ease,
box-shadow 0.18s ease;
}
.lineage-node:hover,
.lineage-node:focus-visible {
transform: translateY(-1px);
border-color: #8fc2a9;
box-shadow: 0 8px 20px rgba(29, 83, 62, 0.11);
}
.lineage-node.selected {
background: #f3f9f6;
border-color: #78b597;
box-shadow: inset 3px 0 var(--green);
}
.lineage-node.has-gap {
background: #fffcf5;
border-color: #e8d29f;
}
.lineage-node-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 8px;
}
.lineage-node-top em,
.lineage-node-top i {
padding: 4px 6px;
border-radius: 6px;
font-style: normal;
font-weight: 700;
}
.lineage-node-top em {
color: var(--green);
background: var(--green-soft);
}
.lineage-node-top em.optional {
color: #4e718e;
background: var(--blue-soft);
}
.lineage-node-top i.ready {
color: var(--green);
background: #eff7f2;
}
.lineage-node-top i.gap {
color: #966214;
background: var(--amber-soft);
}
.lineage-node > strong,
.lineage-node > small {
display: block;
}
.lineage-node > strong {
overflow: hidden;
font-size: 14px;
line-height: 1.45;
text-overflow: ellipsis;
}
.lineage-node > small {
min-height: 38px;
margin-top: 5px;
overflow: hidden;
color: var(--muted);
line-height: 1.5;
}
.lineage-node-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-top: 8px;
padding-top: 8px;
color: var(--muted);
border-top: 1px solid #edf0ed;
}
.lineage-node-meta b {
color: var(--green);
}
.lineage-node-gap {
display: block;
margin-top: 7px;
padding: 6px 7px;
overflow: hidden;
color: #8c621b;
background: #fff5db;
border-radius: 6px;
line-height: 1.45;
text-overflow: ellipsis;
}
.lineage-report-node > strong {
white-space: normal;
}
.lineage-report-group {
min-width: 0;
overflow: hidden;
background: white;
border: 1px solid #e2e7e3;
border-radius: 9px;
}
.lineage-report-group > summary {
display: grid;
grid-template-columns: minmax(0, 1fr) auto 12px;
align-items: center;
gap: 8px;
padding: 10px;
color: var(--ink);
background: #fafbf9;
cursor: pointer;
list-style: none;
}
.lineage-report-group > summary::-webkit-details-marker {
display: none;
}
.lineage-report-group > summary::after {
width: 7px;
height: 7px;
border-right: 2px solid #819088;
border-bottom: 2px solid #819088;
content: "";
transform: rotate(45deg) translateY(-2px);
transition: transform 0.16s ease;
}
.lineage-report-group[open] > summary::after {
transform: rotate(225deg) translate(-2px, -1px);
}
.lineage-report-group > summary span,
.lineage-report-group > summary strong,
.lineage-report-group > summary small {
display: block;
min-width: 0;
}
.lineage-report-group > summary small {
margin-top: 3px;
color: var(--muted);
}
.lineage-report-group > summary em {
padding: 4px 6px;
color: #805b16;
background: var(--amber-soft);
border-radius: 6px;
font-style: normal;
font-weight: 700;
white-space: nowrap;
}
.lineage-report-group-list {
display: grid;
max-height: 360px;
gap: 7px;
padding: 8px;
overflow-y: auto;
border-top: 1px solid #e8ede9;
}
.lineage-topic-node {
display: grid;
grid-template-columns: 30px minmax(0, 1fr);
gap: 3px 9px;
padding: 10px;
background: #f8fbfd;
border: 1px solid #dfe8ef;
border-radius: 9px;
}
.lineage-topic-node > span {
display: grid;
width: 30px;
height: 30px;
grid-row: 1 / 3;
place-items: center;
color: #467594;
background: #e5f0f7;
border-radius: 7px;
font-weight: 800;
}
.lineage-topic-node strong {
align-self: end;
line-height: 1.4;
}
.lineage-topic-node small {
color: var(--muted);
line-height: 1.4;
}
.lineage-connector {
display: grid;
min-height: 120px;
align-content: center;
justify-items: center;
gap: 9px;
color: var(--muted);
}
.lineage-connector span {
text-align: center;
}
.lineage-connector i {
position: relative;
display: block;
width: 38px;
height: 2px;
background: #9fb2a8;
}
.lineage-connector i::after {
position: absolute;
top: -4px;
right: -1px;
width: 8px;
height: 8px;
border-top: 2px solid #789286;
border-right: 2px solid #789286;
content: "";
transform: rotate(45deg);
}
.lineage-connector b {
position: absolute;
top: -3px;
left: 4px;
width: 8px;
height: 8px;
background: var(--lime);
border: 2px solid white;
border-radius: 50%;
box-shadow: 0 0 0 1px #9fb2a8;
}
.lineage-empty-node {
display: grid;
min-height: 150px;
place-items: center;
align-content: center;
gap: 7px;
padding: 20px;
color: var(--muted);
text-align: center;
}
.lineage-empty-node svg {
width: 28px;
height: 28px;
color: #91a49a;
}
.lineage-empty-node strong,
.lineage-empty-node small {
display: block;
}
.lineage-gap-section {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid var(--line-soft);
}
.lineage-gap-heading {
display: flex;
align-items: end;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
}
.lineage-gap-heading span {
color: #ad741d;
font-weight: 800;
letter-spacing: 0.08em;
}
.lineage-gap-heading h3 {
margin: 4px 0 0;
font-size: 16px;
}
.lineage-gap-heading > strong {
padding: 6px 9px;
color: #956313;
background: var(--amber-soft);
border-radius: 7px;
}
.lineage-gap-list {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 9px;
}
.lineage-gap-card {
display: flex;
min-width: 0;
flex-direction: column;
padding: 13px;
background: #fffcf5;
border: 1px solid #ead8ad;
border-radius: 11px;
}
.lineage-gap-card.blocking {
background: #fff8f4;
border-color: #ebc5b5;
}
.lineage-gap-card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.lineage-gap-card-head span {
display: inline-flex;
align-items: center;
gap: 6px;
color: #a06611;
font-weight: 800;
}
.lineage-gap-card.blocking .lineage-gap-card-head span {
color: #a34f31;
}
.lineage-gap-card-head span i {
width: 7px;
height: 7px;
background: var(--amber);
border-radius: 50%;
}
.lineage-gap-card.blocking .lineage-gap-card-head span i {
background: #c96643;
}
.lineage-gap-card-head em {
color: var(--muted);
font-style: normal;
}
.lineage-gap-card > strong,
.lineage-gap-card > small {
display: block;
}
.lineage-gap-card > strong {
margin-top: 10px;
line-height: 1.5;
}
.lineage-gap-card > small {
margin-top: 4px;
color: var(--muted);
line-height: 1.5;
}
.lineage-gap-fields {
display: flex;
flex-wrap: wrap;
gap: 5px;
margin-top: 9px;
}
.lineage-gap-fields span {
padding: 4px 6px;
color: #81591a;
background: #fff1cb;
border-radius: 5px;
font-family: "SFMono-Regular", Consolas, monospace;
}
.lineage-gap-card.blocking .lineage-gap-fields span {
color: #944a30;
background: #fee9df;
}
.lineage-gap-card p {
margin: 10px 0;
color: var(--ink-soft);
line-height: 1.6;
}
.lineage-gap-card dl {
display: grid;
gap: 6px;
margin: auto 0 10px;
padding: 9px;
background: rgba(255, 255, 255, 0.75);
border-radius: 8px;
}
.lineage-gap-card dl div {
display: grid;
grid-template-columns: 74px minmax(0, 1fr);
gap: 7px;
}
.lineage-gap-card dt {
color: var(--muted);
}
.lineage-gap-card dd {
min-width: 0;
margin: 0;
font-weight: 700;
}
.lineage-gap-card > button {
width: 100%;
min-height: 36px;
color: #8c5d12;
background: white;
border: 1px solid #e3cd9c;
border-radius: 7px;
font-weight: 700;
}
.lineage-gap-card.blocking > button {
color: #9a4d32;
border-color: #e6b8a5;
}
.lineage-gap-card > button:hover {
background: #fff7e5;
}
.lineage-gap-empty {
display: flex;
grid-column: 1 / -1;
min-height: 90px;
align-items: center;
justify-content: center;
gap: 12px;
padding: 18px;
color: var(--muted);
background: #f4f8f5;
border: 1px dashed #cbded2;
border-radius: 11px;
}
.lineage-gap-empty svg {
width: 28px;
height: 28px;
color: var(--green);
}
.lineage-gap-empty strong,
.lineage-gap-empty small {
display: block;
}
.lineage-gap-empty small {
margin-top: 4px;
}
.data-issue-owner-list {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin: 12px 0 0;
}
.data-issue-owner-list div {
padding: 10px;
background: #f5f8f5;
border: 1px solid var(--line-soft);
border-radius: 8px;
}
.data-issue-owner-list dt {
color: var(--muted);
}
.data-issue-owner-list dd {
margin: 5px 0 0;
font-weight: 700;
}
.source-filter-toolbar {
display: flex;
align-items: center;
@@ -6397,6 +7231,30 @@ svg {
grid-template-columns: 1fr;
}
.lineage-stage-grid {
grid-template-columns: 1fr;
}
.lineage-stage-list {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.report-direction .lineage-stage.reports .lineage-stage-list {
grid-template-columns: 1fr;
}
.lineage-connector {
min-height: 36px;
}
.lineage-connector i {
transform: rotate(90deg);
}
.lineage-gap-list {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.platform-metric-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@@ -6487,6 +7345,24 @@ svg {
flex-direction: column;
}
.data-lineage-toolbar {
grid-template-columns: 1fr;
align-items: stretch;
}
.lineage-mode-switch {
display: grid;
grid-template-columns: 1fr 1fr;
}
.lineage-summary-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.lineage-gap-list {
grid-template-columns: 1fr;
}
.tenant-card-grid {
grid-template-columns: 1fr;
}
@@ -6977,6 +7853,54 @@ svg {
width: 100%;
}
.data-lineage-header,
.lineage-gap-heading {
align-items: flex-start;
flex-direction: column;
}
.lineage-snapshot-badge {
align-self: flex-start;
}
.lineage-mode-switch button {
padding: 0 8px;
}
.lineage-summary-grid,
.lineage-stage-list {
grid-template-columns: 1fr;
}
.lineage-legend {
align-items: flex-start;
flex-wrap: wrap;
gap: 8px 12px;
}
.lineage-legend small {
width: 100%;
margin-left: 0;
text-align: left;
}
.data-lineage-canvas {
padding: 10px;
}
.lineage-gap-toggle {
min-height: 34px;
}
.lineage-gap-card dl div {
grid-template-columns: 1fr;
gap: 2px;
}
.data-issue-owner-list {
grid-template-columns: 1fr;
}
.source-license-grid {
grid-template-columns: 1fr;
}
+166
View File
@@ -0,0 +1,166 @@
# 招聘数据源采集实现方案
> 调研基线:2026-08-24。站点条款、robots.txt、页面结构和合作接口可能随时变化,上线前必须重新复核并留存审批证据。
## 1. 实现目标
`admin-tools/crawler/` 负责把来源数据接入 Raw 与 Source 层,再映射到统一契约:
- 外部岗位来源输出 `JOB-SIGNAL V3.0`;
- 校内就业来源输出 `SCHOOL-EMPLOYMENT V2.2`;
- 校内实习来源输出 `SCHOOL-INTERNSHIP V2.1`;
- 原始响应只追加保存,标准记录、隔离记录和运行摘要分别输出;
- 每次运行携带任务编号、连接器版本、采集时间、来源 URL 和 SHA-256。
- 薪资上下限统一折算为税前月薪,同时保留源端 `salary_period` 作为折算证据。
它不是用于绕过站点控制的通用抓取工具。代码不包含验证码处理、登录模拟、浏览器指纹伪装、私有接口逆向、代理池轮换或访问限制规避。
## 2. 强制接入边界
每个外部来源必须同时满足:
1. 已取得适用于目标用途、字段范围和保存期限的书面授权,或确认存在允许该用途的开放许可;
2. 使用官方 API、授权数据包或明确允许自动访问的公开页面;
3. 运行时 robots.txt 允许当前 User-Agent 和 URL;
4. 来源域名在连接器 allowlist 中,禁止任意 URL 和内网地址;
5. 使用可识别的 User-Agent 和真实技术联系邮箱;
6. 遵守来源级请求速率、并发、时间窗、停止条件和数据保留策略;
7. 页面出现登录、验证码、访问验证、封禁提示或条款变化时立即停止,不尝试绕过。
校内来源必须基于学校授权的数据处理协议,使用只读账号、租户隔离和最小字段集。平台禁止接收姓名、身份证号、手机号和明文学号。
## 3. 九个来源的实现方式
| 来源代码 | 数据源 | 实现类 | 接入方式 | 默认网络状态 | 上线前置 |
|---|---|---|---|---|---|
| `BOSS` | BOSS直聘 | `BossConnector` | 官方合作 API 或授权 JSON/NDJSON 数据包 | 禁用 | 合作协议、接口文档、字段与留存授权 |
| `ZHAOPIN` | 智联招聘 | `ZhaopinConnector` | 官方合作 API 或授权 JSON/NDJSON 数据包 | 禁用 | 书面许可;不得使用匿名网页爬虫 |
| `BRZP` | 全国中小企业招聘平台 | `BrzpConnector` | 官方数据接口优先;经许可后可读取公开 JobPosting/列表页 | 禁用 | 来源审批、robots 通过、入口 URL 白名单 |
| `NCSS` | 国家大学生就业服务平台 | `NcssConnector` | 官方合作接口优先;经许可后可读取公开职位/专场页 | 禁用 | 来源审批、robots 通过、入口 URL 白名单 |
| `MOHRSS` | 中国公共招聘网 | `MohrssConnector` | 官方数据交换或提供单位授权数据;公开页面仅用于获准范围 | 禁用 | 对应提供单位或招聘单位授权 |
| `IGUOPIN` | 国聘 | `IguopinConnector` | 官方合作 API 或授权数据包 | 禁用 | 合作协议;不得逆向动态页面私有接口 |
| `HZRC` | 杭州人才网 | `HzrcConnector` | 官方合作接口或书面许可的数据包/公开页面 | 禁用 | 书面许可、低速策略、robots 通过 |
| `SCHOOL_EMPLOYMENT` | 校内就业管理系统 | `SchoolEmploymentConnector` | 只读数据库视图导出、API 或 NDJSON 文件 | 无公网访问 | 租户授权、字段映射、学生令牌化 |
| `SCHOOL_INTERNSHIP` | 校内实习管理系统 | `SchoolInternshipConnector` | 租户 API 或 NDJSON 文件 | 无公网访问 | 租户授权、字段映射、学生令牌化 |
“默认网络状态为禁用”表示仓库代码不会因为安装或定时任务误配置而直接访问来源。操作者必须显式传入来源授权确认、入口 URL 和联系邮箱;商业来源还必须提供授权 feed URL。
### 3.1 BOSS直聘
- 不实现账号登录、扫码、Cookie 复用、验证码识别或页面安全验证绕过;
- 不固化网页私有接口、签名算法或浏览器指纹;
- 连接器只读取合同约定的 API/feed,或离线处理授权交付的 JSON/NDJSON;
- 字段重点为岗位、企业、地区、薪资、职责、技能和源端状态。
### 3.2 智联招聘
- [智联平台用户服务协议](https://rd6.zhaopin.com/aboutus/legal/service) 对未经书面许可的爬虫、自动化脚本和数据抓取有限制;
- 因此 `ZhaopinConnector` 只允许授权 feed/API 和离线数据包,不提供匿名页面采集模式;
- 授权材料编号应写入部署配置和审计系统,不写入代码仓库。
### 3.3 全国中小企业招聘平台
- 官方入口为 [全国中小企业网上百日招聘服务平台](https://brzp.chinasme.cn/);
- 优先申请结构化数据交换;公开页面模式只解析标准 `JobPosting` JSON-LD 或经版本化配置确认的页面;
- 活动页和岗位页分开建立水位,避免每日重复抓取全部历史活动。
### 3.4 国家大学生就业服务平台
- 官方入口为 [国家大学生就业服务平台](https://www.ncss.cn/);
- 职位、实习、专场和联合招聘应分别分片;
- 只处理无需登录且获准自动访问的页面或官方合作接口,不采集求职者、简历和账号数据。
### 3.5 中国公共招聘网
- 官方入口为 [中国公共招聘网](http://job.mohrss.gov.cn/);
- 其[网站声明](http://www.job.mohrss.gov.cn/cjobs/jobfairinfo/wzsyzn)提示招聘信息转载需联系相应提供单位或招聘单位取得合法授权;
- 生产接入优先使用官方数据交换。页面连接器必须绑定具体授权范围,不得因站点可公开浏览就默认允许批量复制。
### 3.6 国聘
- 官方入口为 [国聘](https://www.iguopin.com/);
- 页面高度动态化时不逆向私有 JSON 接口,也不使用浏览器登录会话;
- 只接入官方合作 API、企业授权导出或离线数据包。
### 3.7 杭州人才网
- 官方入口为 [杭州人才网](https://www.hzrc.com/);
- 站内招聘活动页面存在“未经书面许可严禁转载和复制”提示,因此必须先取得许可;
- 即使获准,默认仍采用低并发、长间隔、延迟熔断和单分片重试,响应异常时停止而不是提升并发。
### 3.8 校内就业管理系统
- 不是网页爬虫,使用学校提供的只读视图、API 或标准文件;
- 来源记录在学校侧先生成不可逆 `student_token`;
- 连接器拒绝包含姓名、身份证、手机号、邮箱或明文学号的输入;
- 以 `tenant_id + source_record_id + source_updated_at` 形成增量和幂等边界。
### 3.9 校内实习管理系统
- 不是网页爬虫,使用租户 API 或标准文件;
- 只接收学生令牌、专业、企业、岗位、起止日期、状态、工资和业务评价;
- 单租户认证、任务、水位、Raw、隔离区和输出目录均独立;
- 不接收敏感附件,证据只保存学校系统内的引用编号。
## 4. 运行路径
```text
来源审批
→ probe(robots / allowlist / 内容类型 / 小样本结构)
→ collect(严格记录数、页面数、速率和时间上限)
→ raw snapshot(原始载荷 + 元数据 + SHA-256)
→ parse(来源字段)
→ normalize(JOB-SIGNAL / SCHOOL-*)
→ schema validate
→ standard.ndjson / quarantine.ndjson / summary.json
→ 质量门禁与候选版本
```
失败分类至少包括:
- `AUTHORIZATION_REQUIRED`:没有显式授权确认;
- `ROBOTS_DENIED`:robots.txt 不允许;
- `HOST_NOT_ALLOWED`:入口或重定向不在来源 allowlist;
- `PRIVATE_ADDRESS_BLOCKED`:域名解析到内网、回环或保留地址;
- `AUTH_OR_CHALLENGE`:登录、验证码或访问验证;
- `SOURCE_STRUCTURE_CHANGED`:页面或数据字段不满足版本化解析规则;
- `SCHEMA_VALIDATION_FAILED`:标准记录不满足目标契约;
- `PII_REJECTED`:校内输入包含禁止字段。
## 5. 输出和证据
每个任务目录:
```text
外部来源:output/<source>/<task-id>/
校内来源:output/<source>/<tenant-id>/<task-id>/
├── raw/
│ ├── <sha256>.<json|html|bin>
│ └── <sha256>.metadata.json
├── standard.ndjson
├── quarantine.ndjson
└── summary.json
```
安全要求:
- URL 中的 token、key、signature 等敏感查询参数在日志和元数据中脱敏;
- Authorization、Cookie 和代理凭据仅从环境变量读取,不写入 Raw、日志或异常文本;
- 原始页面不得包含简历、求职者联系方式或校内明文身份信息;
- 任务重试使用相同幂等键,不重复生成业务记录;
- Raw 文件只追加,水位仅在任务完整成功后提交。
## 6. 上线验收
每个来源必须提供:
1. 授权编号、授权范围、有效期、允许字段、允许用途和删除要求;
2. robots.txt 与站点条款快照;
3. 100 条脱敏样本和目标字段映射;
4. 正常、空结果、失效岗位、限流、超时、结构变化和重复数据测试;
5. 请求速率与业务方确认记录;
6. 解析器历史快照回放;
7. Schema、完整性、唯一性、时效和敏感字段检查;
8. 连接器版本、镜像摘要、责任人、告警和回滚版本。
只有以上证据齐全,来源注册中心才能把连接器从 `disabled` 提升到 `approved`,调度器才允许创建公网任务。
+27 -3
View File
@@ -83,6 +83,25 @@
未达到阻断阈值的数据不得进入生产分析快照。
### 4.3 数据与报告双向依赖
每个报告模板必须登记版本化的数据依赖,不允许只在报告生成代码中形成不可见的隐式关系。依赖至少包含:
- 报告模板、报告类型和适用组织范围;
- 数据源、数据契约和最低版本;
- `required`(必需)或 `optional`(可选增强)关系;
- 支撑的指标或分析主题;
- 最低完整率、质量阈值、时效与授权要求;
- 缺失字段、降权或阻断策略;
- 业务索取人、技术接口人、要求完成日期和处理状态。
产品提供两个方向的查询:
1. 从报告反查必需和可选数据,计算当前完备度并列出缺口;
2. 从数据反查可形成的分析主题、报告类型、具体报告和影响范围。
依赖元数据用于数据治理和补数协同,可以向获得数据运维权限的角色开放;报告正文、明细指标和样本仍按组织、角色与数据范围独立鉴权。查看依赖关系不得自动授予报告阅读权限。
## 5. 导入与发布流程
### 5.1 初次导入
@@ -126,7 +145,8 @@
- `Tenant`、`Organization`、`User`、`Role`、`DataScope`;
- `DataSource`、`DataContract`、`SchemaVersion`、`ImportJob`;
- `DataVersion`、`QualityRule`、`QualityIssue`、`Authorization`;
- `AnalysisSnapshot`、`MetricDefinition`、`EvidenceReference`;
- `AnalysisSnapshot`、`MetricDefinition`、`AnalysisTopic`、`EvidenceReference`;
- `ReportDataDependency`、`DataRequest`、`DataRequestAssignment`;
- `Program`、`Course`、`Skill`、`JobSignal`、`EnterpriseDemand`;
- `CollaborationProject`、`ImprovementTask`、`Milestone`、`AcceptanceEvidence`;
- `ReportTemplate`、`ReportVersion`、`AuditEvent`.
@@ -164,6 +184,9 @@
- 报告提交人与审批人必须分离,非校级管理者不能审批发布;报告未批准时不能发布,未正式发布时反馈阻断且下载明确标识非正式版;
- 专业四个标签页均有完整内容和证据入口;
- 数据源可查看更新日期、更新内容、数据要求、版本任务和授权审计;
- 42 份报告均可反查必需和可选数据,6 个数据源均可反查支撑的分析主题与报告;
- 缺口视图展示缺失字段、完整率、影响报告、业务索取人、技术接口人、完成日期与状态,并可进入质量处置;
- 依赖元数据可见性与报告正文权限分离,未授权角色不能从图谱绕过报告阅读权限;
- 新建任务、项目和需求后立即进入对应列表;
- 报告建议加入任务后可在任务中心追踪;
- 报告生成后新增可审核版本;
@@ -182,5 +205,6 @@
4. 校企协同展示企业需求到项目验收;
5. 闭环工作台依次切换企业导师、技术管理员、专业负责人、校级管理者和就业老师验证状态流;
6. 改进任务展示负责人、进度和验收证据;
7. 切换校级技术管理员,展示数据导入、更新、质量、接收反馈和审计;
8. 打开全校报告和供需会展示大屏完成交付说明。
7. 在数据资产页双向演示“报告需要哪些数据”和“数据可形成哪些报告”,切换只看缺口并进入补数责任处置;
8. 切换校级技术管理员,展示数据导入、更新、质量、接收反馈和审计;
9. 打开全校报告和供需会展示大屏完成交付说明。