jobpicky 0.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. jobpicky/__init__.py +3 -0
  2. jobpicky/__main__.py +4 -0
  3. jobpicky/alerts.py +32 -0
  4. jobpicky/audit.py +120 -0
  5. jobpicky/backup.py +109 -0
  6. jobpicky/cli.py +833 -0
  7. jobpicky/config.py +320 -0
  8. jobpicky/diagnostics.py +28 -0
  9. jobpicky/error_safety.py +107 -0
  10. jobpicky/exporters.py +144 -0
  11. jobpicky/feishu.py +326 -0
  12. jobpicky/feishu_records.py +60 -0
  13. jobpicky/launcher.py +49 -0
  14. jobpicky/logging_utils.py +59 -0
  15. jobpicky/matcher.py +266 -0
  16. jobpicky/models.py +118 -0
  17. jobpicky/normalizer.py +147 -0
  18. jobpicky/official_search.py +329 -0
  19. jobpicky/onboarding.py +113 -0
  20. jobpicky/paths.py +87 -0
  21. jobpicky/pipeline.py +266 -0
  22. jobpicky/resources/__init__.py +1 -0
  23. jobpicky/resources/jobs_seed.sqlite +0 -0
  24. jobpicky/run_guard.py +132 -0
  25. jobpicky/runtime.py +88 -0
  26. jobpicky/seed.py +58 -0
  27. jobpicky/services/__init__.py +1 -0
  28. jobpicky/services/initialization.py +86 -0
  29. jobpicky/services/migration.py +78 -0
  30. jobpicky/services/scanning.py +619 -0
  31. jobpicky/services/synchronization.py +118 -0
  32. jobpicky/services/web_state.py +137 -0
  33. jobpicky/storage.py +771 -0
  34. jobpicky/web/__init__.py +1 -0
  35. jobpicky/web/app.py +391 -0
  36. jobpicky/web/templates/index.html +449 -0
  37. jobpicky/wondercv.py +685 -0
  38. jobpicky/workspace_provisioner.py +219 -0
  39. jobpicky/workspace_schema.py +112 -0
  40. jobpicky-0.2.1.dist-info/METADATA +39 -0
  41. jobpicky-0.2.1.dist-info/RECORD +45 -0
  42. jobpicky-0.2.1.dist-info/WHEEL +5 -0
  43. jobpicky-0.2.1.dist-info/entry_points.txt +2 -0
  44. jobpicky-0.2.1.dist-info/licenses/LICENSE +21 -0
  45. jobpicky-0.2.1.dist-info/top_level.txt +1 -0
jobpicky/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ __all__ = ["__version__"]
2
+
3
+ __version__ = "0.2.1"
jobpicky/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .launcher import main
2
+
3
+
4
+ raise SystemExit(main())
jobpicky/alerts.py ADDED
@@ -0,0 +1,32 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from .error_safety import redact_text
6
+
7
+
8
+ def build_daily_message(total_new: int, relevant_rows: list[dict[str, Any]], error: str | None = None) -> str:
9
+ lines = [f"今日新增秋招信息:{total_new} 条", f"推荐岗位:{len(relevant_rows)} 条"]
10
+ if error:
11
+ error = redact_text(error)
12
+ lines.append(f"抓取异常:{error}")
13
+ if not relevant_rows:
14
+ lines.append("暂无需要推送的推荐岗位。")
15
+ return "\n".join(lines)
16
+
17
+ for index, row in enumerate(relevant_rows[:20], start=1):
18
+ company = row.get("company") or "未知公司"
19
+ title = row.get("title") or row.get("clean_title") or "未命名公告"
20
+ city = row.get("city") or "城市待确认"
21
+ link = row.get("original_url") or row.get("detail_url") or row.get("apply_url") or ""
22
+ lines.extend(
23
+ [
24
+ "",
25
+ f"{index}. {company} - {title} - {city}",
26
+ ]
27
+ )
28
+ if link:
29
+ lines.append(str(link))
30
+ if len(relevant_rows) > 20:
31
+ lines.append(f"\n其余 {len(relevant_rows) - 20} 条请在飞书视图查看。")
32
+ return "\n".join(lines)
jobpicky/audit.py ADDED
@@ -0,0 +1,120 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Any, Iterable
5
+
6
+ from .storage import JobRepository
7
+
8
+
9
+ KNOWN_STATUSES = frozenset({"待处理", "收藏", "不合适", "已投递", "笔试中", "面试中", "Offer", "已结束"})
10
+
11
+
12
+ @dataclass(frozen=True, slots=True)
13
+ class FeishuAuditReport:
14
+ local_job_count: int
15
+ remote_record_count: int
16
+ only_local_job_ids: list[int] = field(default_factory=list)
17
+ only_remote_record_ids: list[str] = field(default_factory=list)
18
+ duplicate_job_ids: list[int] = field(default_factory=list)
19
+ blank_record_ids: list[str] = field(default_factory=list)
20
+ unmatched_record_ids: list[str] = field(default_factory=list)
21
+ unknown_statuses: dict[str, str] = field(default_factory=dict)
22
+
23
+
24
+ @dataclass(frozen=True, slots=True)
25
+ class StateRecoveryResult:
26
+ updated_count: int
27
+ unknown_statuses: dict[str, str]
28
+ skipped_record_ids: list[str]
29
+
30
+
31
+ def audit_feishu_records(repo: JobRepository, records: Iterable[dict[str, Any]]) -> FeishuAuditReport:
32
+ record_list = list(records)
33
+ record_to_job = repo.sync_job_ids_by_record_id()
34
+ local_ids = set(record_to_job.values())
35
+ matched_ids: set[int] = set()
36
+ seen_ids: set[int] = set()
37
+ duplicates: set[int] = set()
38
+ only_remote: list[str] = []
39
+ blank: list[str] = []
40
+ unmatched: list[str] = []
41
+ unknown: dict[str, str] = {}
42
+
43
+ for index, record in enumerate(record_list):
44
+ record_id = str(record.get("record_id") or f"record-{index + 1}")
45
+ if not record.get("record_id"):
46
+ blank.append(record_id)
47
+ continue
48
+ job_id = record_to_job.get(record_id)
49
+ if job_id in seen_ids:
50
+ duplicates.add(job_id)
51
+ seen_ids.add(job_id)
52
+ if job_id is None:
53
+ only_remote.append(record_id)
54
+ else:
55
+ matched_ids.add(job_id)
56
+ status = _text(_fields(record).get("求职状态")).strip()
57
+ if status and normalize_status(status) is None:
58
+ unknown[record_id] = status
59
+
60
+ return FeishuAuditReport(
61
+ local_job_count=len(local_ids),
62
+ remote_record_count=len(record_list),
63
+ only_local_job_ids=sorted(local_ids - matched_ids),
64
+ only_remote_record_ids=only_remote,
65
+ duplicate_job_ids=sorted(duplicates),
66
+ blank_record_ids=blank,
67
+ unmatched_record_ids=unmatched,
68
+ unknown_statuses=unknown,
69
+ )
70
+
71
+
72
+ def recover_user_states(repo: JobRepository, records: Iterable[dict[str, Any]]) -> StateRecoveryResult:
73
+ record_list = list(records)
74
+ report = audit_feishu_records(repo, record_list)
75
+ record_to_job = repo.sync_job_ids_by_record_id()
76
+ skipped = set(report.only_remote_record_ids + report.blank_record_ids + report.unmatched_record_ids)
77
+ duplicate_ids = set(report.duplicate_job_ids)
78
+ updated = 0
79
+ for index, record in enumerate(record_list):
80
+ record_id = str(record.get("record_id") or f"record-{index + 1}")
81
+ fields = _fields(record)
82
+ job_id = record_to_job.get(record_id)
83
+ if job_id in duplicate_ids:
84
+ skipped.add(record_id)
85
+ if record_id in skipped or record_id in report.unknown_statuses:
86
+ continue
87
+ status = normalize_status(_text(fields.get("求职状态")).strip())
88
+ if job_id is None or status is None:
89
+ continue
90
+ repo.update_user_state(
91
+ job_id,
92
+ status,
93
+ _text(fields.get("备注")),
94
+ apply_url_manual=None,
95
+ )
96
+ if record.get("record_id"):
97
+ repo.mark_sync(job_id, "synced", record_id=str(record["record_id"]))
98
+ updated += 1
99
+ return StateRecoveryResult(updated_count=updated, unknown_statuses=report.unknown_statuses, skipped_record_ids=sorted(skipped))
100
+
101
+
102
+ def normalize_status(value: str) -> str | None:
103
+ return value if value in KNOWN_STATUSES else None
104
+
105
+
106
+ def _fields(record: dict[str, Any]) -> dict[str, Any]:
107
+ fields = record.get("fields", {})
108
+ return fields if isinstance(fields, dict) else {}
109
+
110
+
111
+ def _text(value: Any) -> str:
112
+ if isinstance(value, list):
113
+ return "".join(_text(item) for item in value)
114
+ if isinstance(value, dict):
115
+ return str(value.get("text") or value.get("link") or "")
116
+ return "" if value is None else str(value)
117
+
118
+
119
+ def _link_or_text(value: Any) -> str:
120
+ return _text(value)
jobpicky/backup.py ADDED
@@ -0,0 +1,109 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import os
6
+ import re
7
+ import sqlite3
8
+ from dataclasses import dataclass
9
+ from datetime import datetime, timezone
10
+ from pathlib import Path
11
+ from typing import Any, Iterable
12
+
13
+
14
+ @dataclass(frozen=True, slots=True)
15
+ class SQLiteBackup:
16
+ backup_path: Path
17
+ metadata_path: Path
18
+
19
+
20
+ class BackupService:
21
+ """Create self-describing, restorable snapshots without touching the source."""
22
+
23
+ def __init__(self, backup_directory: str | Path):
24
+ self.backup_directory = Path(backup_directory)
25
+
26
+ def backup_sqlite(self, database_path: str | Path, *, source: str) -> SQLiteBackup:
27
+ database_path = Path(database_path)
28
+ if not database_path.is_file():
29
+ raise FileNotFoundError(database_path)
30
+
31
+ self.backup_directory.mkdir(parents=True, exist_ok=True)
32
+ created_at = _utc_now()
33
+ filename = f"{_safe_name(source)}-{database_path.stem}-{created_at.replace(':', '').replace('-', '')}.sqlite"
34
+ backup_path = self.backup_directory / filename
35
+ temporary_path = backup_path.with_suffix(".sqlite.tmp")
36
+ try:
37
+ source_connection = sqlite3.connect(database_path)
38
+ target_connection = sqlite3.connect(temporary_path)
39
+ try:
40
+ source_connection.backup(target_connection)
41
+ finally:
42
+ target_connection.close()
43
+ source_connection.close()
44
+ os.replace(temporary_path, backup_path)
45
+ finally:
46
+ if temporary_path.exists():
47
+ temporary_path.unlink()
48
+
49
+ metadata = {
50
+ "format_version": 1,
51
+ "created_at": created_at,
52
+ "source": source,
53
+ "backup_file": backup_path.name,
54
+ "sha256": _sha256(backup_path),
55
+ }
56
+ metadata_path = backup_path.with_suffix(".json")
57
+ _write_json(metadata_path, metadata)
58
+ return SQLiteBackup(backup_path=backup_path, metadata_path=metadata_path)
59
+
60
+
61
+ def write_feishu_backup(
62
+ pages: Iterable[Iterable[dict[str, Any]]], backup_directory: str | Path, *, source: str = "feishu"
63
+ ) -> Path:
64
+ """Persist already-fetched Feishu pages while removing credential-shaped fields."""
65
+ directory = Path(backup_directory)
66
+ directory.mkdir(parents=True, exist_ok=True)
67
+ created_at = _utc_now()
68
+ output = directory / f"{_safe_name(source)}-records-{created_at.replace(':', '').replace('-', '')}.json"
69
+ records = [_redact_credentials(record) for page in pages for record in page]
70
+ _write_json(output, {"format_version": 1, "created_at": created_at, "source": source, "records": records})
71
+ return output
72
+
73
+
74
+ def _utc_now() -> str:
75
+ return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
76
+
77
+
78
+ def _safe_name(value: str) -> str:
79
+ return re.sub(r"[^A-Za-z0-9_.-]+", "-", value).strip("-") or "backup"
80
+
81
+
82
+ def _sha256(path: Path) -> str:
83
+ digest = hashlib.sha256()
84
+ with path.open("rb") as stream:
85
+ for chunk in iter(lambda: stream.read(1024 * 1024), b""):
86
+ digest.update(chunk)
87
+ return digest.hexdigest()
88
+
89
+
90
+ def _write_json(path: Path, payload: dict[str, Any]) -> None:
91
+ temporary_path = path.with_suffix(path.suffix + ".tmp")
92
+ try:
93
+ temporary_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
94
+ os.replace(temporary_path, path)
95
+ finally:
96
+ if temporary_path.exists():
97
+ temporary_path.unlink()
98
+
99
+
100
+ def _redact_credentials(value: Any) -> Any:
101
+ if isinstance(value, dict):
102
+ return {
103
+ key: _redact_credentials(item)
104
+ for key, item in value.items()
105
+ if not any(marker in key.lower() for marker in ("secret", "token", "authorization", "webhook", "password"))
106
+ }
107
+ if isinstance(value, list):
108
+ return [_redact_credentials(item) for item in value]
109
+ return value