hh-applicant-tool 1.4.7__py3-none-any.whl → 1.4.12__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.
- hh_applicant_tool/__main__.py +1 -1
- hh_applicant_tool/ai/openai.py +2 -2
- hh_applicant_tool/api/__init__.py +4 -2
- hh_applicant_tool/api/client.py +23 -12
- hh_applicant_tool/{constants.py → api/client_keys.py} +3 -3
- hh_applicant_tool/{datatypes.py → api/datatypes.py} +2 -0
- hh_applicant_tool/api/errors.py +8 -2
- hh_applicant_tool/{utils → api}/user_agent.py +1 -1
- hh_applicant_tool/main.py +12 -13
- hh_applicant_tool/operations/apply_similar.py +125 -47
- hh_applicant_tool/operations/authorize.py +82 -25
- hh_applicant_tool/operations/call_api.py +3 -3
- hh_applicant_tool/operations/{check_negotiations.py → clear_negotiations.py} +40 -25
- hh_applicant_tool/operations/list_resumes.py +5 -7
- hh_applicant_tool/operations/query.py +3 -1
- hh_applicant_tool/operations/reply_employers.py +80 -40
- hh_applicant_tool/operations/settings.py +2 -2
- hh_applicant_tool/operations/update_resumes.py +5 -4
- hh_applicant_tool/operations/whoami.py +1 -1
- hh_applicant_tool/storage/__init__.py +5 -1
- hh_applicant_tool/storage/facade.py +2 -2
- hh_applicant_tool/storage/models/base.py +4 -4
- hh_applicant_tool/storage/models/contacts.py +28 -0
- hh_applicant_tool/storage/queries/schema.sql +22 -9
- hh_applicant_tool/storage/repositories/base.py +69 -15
- hh_applicant_tool/storage/repositories/contacts.py +5 -10
- hh_applicant_tool/storage/repositories/employers.py +1 -0
- hh_applicant_tool/storage/repositories/errors.py +19 -0
- hh_applicant_tool/storage/repositories/negotiations.py +1 -0
- hh_applicant_tool/storage/repositories/resumes.py +2 -7
- hh_applicant_tool/storage/repositories/settings.py +1 -0
- hh_applicant_tool/storage/repositories/vacancies.py +1 -0
- hh_applicant_tool/storage/utils.py +6 -15
- hh_applicant_tool/utils/__init__.py +3 -3
- hh_applicant_tool/utils/config.py +1 -1
- hh_applicant_tool/utils/log.py +4 -1
- hh_applicant_tool/utils/mixins.py +20 -19
- hh_applicant_tool/utils/terminal.py +13 -0
- {hh_applicant_tool-1.4.7.dist-info → hh_applicant_tool-1.4.12.dist-info}/METADATA +197 -140
- hh_applicant_tool-1.4.12.dist-info/RECORD +68 -0
- hh_applicant_tool/storage/models/contact.py +0 -16
- hh_applicant_tool-1.4.7.dist-info/RECORD +0 -67
- /hh_applicant_tool/utils/{dateutil.py → date.py} +0 -0
- /hh_applicant_tool/utils/{jsonutil.py → json.py} +0 -0
- {hh_applicant_tool-1.4.7.dist-info → hh_applicant_tool-1.4.12.dist-info}/WHEEL +0 -0
- {hh_applicant_tool-1.4.7.dist-info → hh_applicant_tool-1.4.12.dist-info}/entry_points.txt +0 -0
|
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import sqlite3
|
|
4
4
|
|
|
5
|
-
from .repositories.contacts import
|
|
5
|
+
from .repositories.contacts import VacancyContactsRepository
|
|
6
6
|
from .repositories.employers import EmployersRepository
|
|
7
7
|
from .repositories.negotiations import NegotiationRepository
|
|
8
8
|
from .repositories.resumes import ResumesRepository
|
|
@@ -18,7 +18,7 @@ class StorageFacade:
|
|
|
18
18
|
init_db(conn)
|
|
19
19
|
self.employers = EmployersRepository(conn)
|
|
20
20
|
self.vacancies = VacanciesRepository(conn)
|
|
21
|
-
self.
|
|
21
|
+
self.vacancy_contacts = VacancyContactsRepository(conn)
|
|
22
22
|
self.negotiations = NegotiationRepository(conn)
|
|
23
23
|
self.settings = SettingsRepository(conn)
|
|
24
24
|
self.resumes = ResumesRepository(conn)
|
|
@@ -4,8 +4,8 @@ from datetime import datetime
|
|
|
4
4
|
from logging import getLogger
|
|
5
5
|
from typing import Any, Callable, Mapping, Self, dataclass_transform, get_origin
|
|
6
6
|
|
|
7
|
-
from hh_applicant_tool.utils import
|
|
8
|
-
from hh_applicant_tool.utils.
|
|
7
|
+
from hh_applicant_tool.utils import json
|
|
8
|
+
from hh_applicant_tool.utils.date import try_parse_datetime
|
|
9
9
|
|
|
10
10
|
logger = getLogger(__package__)
|
|
11
11
|
|
|
@@ -48,7 +48,7 @@ class BaseModel:
|
|
|
48
48
|
if value is MISSING:
|
|
49
49
|
continue
|
|
50
50
|
if f.metadata.get("store_json"):
|
|
51
|
-
value =
|
|
51
|
+
value = json.dumps(value)
|
|
52
52
|
# Точно не нужно типы приводить перед сохранением
|
|
53
53
|
# else:
|
|
54
54
|
# value = self._coerce_type(value, f)
|
|
@@ -118,7 +118,7 @@ class BaseModel:
|
|
|
118
118
|
continue
|
|
119
119
|
|
|
120
120
|
if f.metadata.get("store_json"):
|
|
121
|
-
value =
|
|
121
|
+
value = json.loads(value)
|
|
122
122
|
else:
|
|
123
123
|
value = cls._coerce_type(value, f)
|
|
124
124
|
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from .base import BaseModel, mapped
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
# Из вакансии извлекается
|
|
5
|
+
class VacancyContactsModel(BaseModel):
|
|
6
|
+
id: int
|
|
7
|
+
vacancy_id: int = mapped(path="id")
|
|
8
|
+
|
|
9
|
+
vacancy_name: str = mapped(path="name")
|
|
10
|
+
vacancy_alternate_url: str = mapped(path="alternate_url", default=None)
|
|
11
|
+
vacancy_area_id: int = mapped(path="area.id", default=None)
|
|
12
|
+
vacancy_area_name: str = mapped(path="area.name", default=None)
|
|
13
|
+
vacancy_salary_from: int = mapped(path="salary.from", default=0)
|
|
14
|
+
vacancy_salary_to: int = mapped(path="salary.to", default=0)
|
|
15
|
+
vacancy_currency: str = mapped(path="salary.currency", default="RUR")
|
|
16
|
+
vacancy_gross: bool = mapped(path="salary.gross", default=False)
|
|
17
|
+
|
|
18
|
+
employer_id: int = mapped(path="employer.id", default=None)
|
|
19
|
+
employer_name: str = mapped(path="employer.name", default=None)
|
|
20
|
+
email: str = mapped(path="contacts.email")
|
|
21
|
+
name: str = mapped(path="contacts.name", default=None)
|
|
22
|
+
phone_numbers: str = mapped(
|
|
23
|
+
path="contacts.phones",
|
|
24
|
+
transform=lambda phones: ", ".join(
|
|
25
|
+
p["formatted"] for p in phones if p.get("number")
|
|
26
|
+
),
|
|
27
|
+
default=None,
|
|
28
|
+
)
|
|
@@ -14,17 +14,29 @@ CREATE TABLE IF NOT EXISTS employers (
|
|
|
14
14
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
15
15
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
16
16
|
);
|
|
17
|
-
/* =====================
|
|
18
|
-
CREATE TABLE IF NOT EXISTS
|
|
17
|
+
/* ===================== contacts ===================== */
|
|
18
|
+
CREATE TABLE IF NOT EXISTS vacancy_contacts (
|
|
19
19
|
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
|
|
20
|
-
|
|
21
|
-
--
|
|
20
|
+
vacancy_id INTEGER NOT NULL,
|
|
21
|
+
-- Все это избыточные поля
|
|
22
|
+
vacancy_alternate_url TEXT,
|
|
23
|
+
vacancy_name TEXT,
|
|
24
|
+
vacancy_area_id INTEGER,
|
|
25
|
+
vacancy_area_name TEXT,
|
|
26
|
+
vacancy_salary_from INTEGER,
|
|
27
|
+
vacancy_salary_to INTEGER,
|
|
28
|
+
vacancy_currency VARCHAR(3),
|
|
29
|
+
vacancy_gross BOOLEAN,
|
|
30
|
+
--
|
|
31
|
+
employer_id INTEGER,
|
|
32
|
+
employer_name TEXT,
|
|
33
|
+
--
|
|
22
34
|
name TEXT,
|
|
23
35
|
email TEXT,
|
|
24
36
|
phone_numbers TEXT NOT NULL,
|
|
25
37
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
26
38
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
27
|
-
UNIQUE (
|
|
39
|
+
UNIQUE (vacancy_id, email)
|
|
28
40
|
);
|
|
29
41
|
/* ===================== vacancies ===================== */
|
|
30
42
|
CREATE TABLE IF NOT EXISTS vacancies (
|
|
@@ -49,7 +61,8 @@ CREATE TABLE IF NOT EXISTS negotiations (
|
|
|
49
61
|
id INTEGER PRIMARY KEY,
|
|
50
62
|
state TEXT NOT NULL,
|
|
51
63
|
vacancy_id INTEGER NOT NULL,
|
|
52
|
-
employer_id INTEGER
|
|
64
|
+
employer_id INTEGER,
|
|
65
|
+
-- Может обнулиться при блокировке раб-о-тодателя
|
|
53
66
|
chat_id INTEGER NOT NULL,
|
|
54
67
|
resume_id TEXT,
|
|
55
68
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
@@ -95,10 +108,10 @@ UPDATE employers
|
|
|
95
108
|
SET updated_at = CURRENT_TIMESTAMP
|
|
96
109
|
WHERE id = OLD.id;
|
|
97
110
|
END;
|
|
98
|
-
CREATE TRIGGER IF NOT EXISTS
|
|
111
|
+
CREATE TRIGGER IF NOT EXISTS trg_vacancy_contacts_updated
|
|
99
112
|
AFTER
|
|
100
|
-
UPDATE ON
|
|
101
|
-
UPDATE
|
|
113
|
+
UPDATE ON vacancy_contacts BEGIN
|
|
114
|
+
UPDATE vacancy_contacts
|
|
102
115
|
SET updated_at = CURRENT_TIMESTAMP
|
|
103
116
|
WHERE id = OLD.id;
|
|
104
117
|
END;
|
|
@@ -4,11 +4,10 @@ import logging
|
|
|
4
4
|
import sqlite3
|
|
5
5
|
from collections.abc import Sequence
|
|
6
6
|
from dataclasses import dataclass
|
|
7
|
-
from functools import cached_property
|
|
8
7
|
from typing import Any, ClassVar, Iterator, Mapping, Self, Type
|
|
9
8
|
|
|
10
9
|
from ..models.base import BaseModel
|
|
11
|
-
from
|
|
10
|
+
from .errors import wrap_db_errors
|
|
12
11
|
|
|
13
12
|
DEFAULT_PRIMARY_KEY = "id"
|
|
14
13
|
|
|
@@ -19,18 +18,23 @@ logger = logging.getLogger(__package__)
|
|
|
19
18
|
class BaseRepository:
|
|
20
19
|
model: ClassVar[Type[BaseModel] | None] = None
|
|
21
20
|
pkey: ClassVar[str] = DEFAULT_PRIMARY_KEY
|
|
21
|
+
conflict_columns: ClassVar[tuple[str, ...] | None] = None
|
|
22
|
+
update_excludes: ClassVar[tuple[str, ...]] = ("created_at", "updated_at")
|
|
23
|
+
__table__: ClassVar[str | None] = None
|
|
22
24
|
|
|
23
25
|
conn: sqlite3.Connection
|
|
24
26
|
auto_commit: bool = True
|
|
25
27
|
|
|
26
|
-
@
|
|
28
|
+
@property
|
|
27
29
|
def table_name(self) -> str:
|
|
28
|
-
return
|
|
30
|
+
return self.__table__ or self.model.__name__
|
|
29
31
|
|
|
32
|
+
@wrap_db_errors
|
|
30
33
|
def commit(self):
|
|
31
34
|
if self.conn.in_transaction:
|
|
32
35
|
self.conn.commit()
|
|
33
36
|
|
|
37
|
+
@wrap_db_errors
|
|
34
38
|
def rollback(self):
|
|
35
39
|
if self.conn.in_transaction:
|
|
36
40
|
self.conn.rollback()
|
|
@@ -53,6 +57,7 @@ class BaseRepository:
|
|
|
53
57
|
data = {col[0]: value for col, value in zip(cursor.description, row)} # noqa: B905
|
|
54
58
|
return self.model.from_db(data)
|
|
55
59
|
|
|
60
|
+
@wrap_db_errors
|
|
56
61
|
def find(self, **kwargs: Any) -> Iterator[BaseModel]:
|
|
57
62
|
# logger.debug(kwargs)
|
|
58
63
|
operators = {
|
|
@@ -94,25 +99,37 @@ class BaseRepository:
|
|
|
94
99
|
if conditions:
|
|
95
100
|
sql += f" WHERE {' AND '.join(conditions)}"
|
|
96
101
|
sql += " ORDER BY rowid DESC;"
|
|
97
|
-
|
|
98
|
-
|
|
102
|
+
try:
|
|
103
|
+
cur = self.conn.execute(sql, sql_params)
|
|
104
|
+
except sqlite3.Error:
|
|
105
|
+
logger.warning("SQL ERROR: %s", sql)
|
|
106
|
+
raise
|
|
107
|
+
|
|
99
108
|
yield from (self._row_to_model(cur, row) for row in cur.fetchall())
|
|
100
109
|
|
|
110
|
+
@wrap_db_errors
|
|
101
111
|
def get(self, pk: Any) -> BaseModel | None:
|
|
102
112
|
return next(self.find(**{f"{self.pkey}": pk}), None)
|
|
103
113
|
|
|
114
|
+
@wrap_db_errors
|
|
104
115
|
def count_total(self) -> int:
|
|
105
116
|
cur = self.conn.execute(f"SELECT count(*) FROM {self.table_name};")
|
|
106
117
|
return cur.fetchone()[0]
|
|
107
118
|
|
|
108
|
-
|
|
119
|
+
@wrap_db_errors
|
|
120
|
+
def delete(self, obj_or_pkey: Any, /, commit: bool | None = None) -> None:
|
|
109
121
|
sql = f"DELETE FROM {self.table_name} WHERE {self.pkey} = ?"
|
|
110
|
-
pk_value =
|
|
122
|
+
pk_value = (
|
|
123
|
+
getattr(obj_or_pkey, self.pkey)
|
|
124
|
+
if isinstance(obj_or_pkey, BaseModel)
|
|
125
|
+
else obj_or_pkey
|
|
126
|
+
)
|
|
111
127
|
self.conn.execute(sql, (pk_value,))
|
|
112
128
|
self.maybe_commit(commit=commit)
|
|
113
129
|
|
|
114
130
|
remove = delete
|
|
115
131
|
|
|
132
|
+
@wrap_db_errors
|
|
116
133
|
def clear(self, commit: bool | None = None):
|
|
117
134
|
self.conn.execute(f"DELETE FROM {self.table_name};")
|
|
118
135
|
self.maybe_commit(commit)
|
|
@@ -121,14 +138,21 @@ class BaseRepository:
|
|
|
121
138
|
|
|
122
139
|
def _insert(
|
|
123
140
|
self,
|
|
124
|
-
data: Mapping[str, Any],
|
|
141
|
+
data: Mapping[str, Any] | list[Mapping[str, Any]],
|
|
125
142
|
/,
|
|
143
|
+
batch: bool = False,
|
|
126
144
|
upsert: bool = True,
|
|
127
145
|
conflict_columns: Sequence[str] | None = None,
|
|
128
|
-
update_excludes: Sequence[str] =
|
|
146
|
+
update_excludes: Sequence[str] | None = None,
|
|
129
147
|
commit: bool | None = None,
|
|
130
148
|
):
|
|
131
|
-
|
|
149
|
+
conflict_columns = conflict_columns or self.conflict_columns
|
|
150
|
+
update_excludes = update_excludes or self.update_excludes
|
|
151
|
+
|
|
152
|
+
if batch and not data:
|
|
153
|
+
return
|
|
154
|
+
|
|
155
|
+
columns = list(dict(data[0] if batch else data).keys())
|
|
132
156
|
sql = (
|
|
133
157
|
f"INSERT INTO {self.table_name} ({', '.join(columns)})"
|
|
134
158
|
f" VALUES (:{', :'.join(columns)})"
|
|
@@ -151,7 +175,10 @@ class BaseRepository:
|
|
|
151
175
|
# 2. Primary key (никогда не меняем)
|
|
152
176
|
# 3. Технические поля (created_at и т.д.)
|
|
153
177
|
update_set = (
|
|
154
|
-
cols_set
|
|
178
|
+
cols_set
|
|
179
|
+
- conflict_set
|
|
180
|
+
- {self.pkey}
|
|
181
|
+
- set(update_excludes or [])
|
|
155
182
|
)
|
|
156
183
|
|
|
157
184
|
if update_set:
|
|
@@ -163,14 +190,41 @@ class BaseRepository:
|
|
|
163
190
|
sql += " DO NOTHING"
|
|
164
191
|
|
|
165
192
|
sql += ";"
|
|
166
|
-
logger.debug("%.2000s", sql)
|
|
167
|
-
|
|
193
|
+
# logger.debug("%.2000s", sql)
|
|
194
|
+
try:
|
|
195
|
+
if batch:
|
|
196
|
+
self.conn.executemany(sql, data)
|
|
197
|
+
else:
|
|
198
|
+
self.conn.execute(sql, data)
|
|
199
|
+
except sqlite3.Error:
|
|
200
|
+
logger.warning("SQL ERROR: %s", sql)
|
|
201
|
+
|
|
202
|
+
raise
|
|
168
203
|
self.maybe_commit(commit)
|
|
169
204
|
|
|
205
|
+
@wrap_db_errors
|
|
170
206
|
def save(
|
|
171
|
-
self,
|
|
207
|
+
self,
|
|
208
|
+
obj: BaseModel | Mapping[str, Any],
|
|
209
|
+
/,
|
|
210
|
+
**kwargs: Any,
|
|
172
211
|
) -> None:
|
|
173
212
|
if isinstance(obj, Mapping):
|
|
174
213
|
obj = self.model.from_api(obj)
|
|
175
214
|
data = obj.to_db()
|
|
176
215
|
self._insert(data, **kwargs)
|
|
216
|
+
|
|
217
|
+
@wrap_db_errors
|
|
218
|
+
def save_batch(
|
|
219
|
+
self,
|
|
220
|
+
items: list[BaseModel | Mapping[str, Any]],
|
|
221
|
+
/,
|
|
222
|
+
**kwargs: Any,
|
|
223
|
+
) -> None:
|
|
224
|
+
if not items:
|
|
225
|
+
return
|
|
226
|
+
data = [
|
|
227
|
+
(self.model.from_api(i) if isinstance(i, Mapping) else i).to_db()
|
|
228
|
+
for i in items
|
|
229
|
+
]
|
|
230
|
+
self._insert(data, batch=True, **kwargs)
|
|
@@ -2,18 +2,13 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import logging
|
|
4
4
|
|
|
5
|
-
from ..models.
|
|
5
|
+
from ..models.contacts import VacancyContactsModel
|
|
6
6
|
from .base import BaseRepository
|
|
7
7
|
|
|
8
8
|
logger = logging.getLogger(__package__)
|
|
9
9
|
|
|
10
10
|
|
|
11
|
-
class
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
# logger.debug(contact)
|
|
16
|
-
super().save(
|
|
17
|
-
contact,
|
|
18
|
-
conflict_columns=["employer_id", "email"],
|
|
19
|
-
)
|
|
11
|
+
class VacancyContactsRepository(BaseRepository):
|
|
12
|
+
__table__ = "vacancy_contacts"
|
|
13
|
+
model = VacancyContactsModel
|
|
14
|
+
conflict_columns = ("vacancy_id", "email")
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import sqlite3
|
|
2
|
+
from functools import wraps
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class RepositoryError(sqlite3.Error):
|
|
6
|
+
pass
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def wrap_db_errors(func):
|
|
10
|
+
@wraps(func)
|
|
11
|
+
def wrapper(*args, **kwargs):
|
|
12
|
+
try:
|
|
13
|
+
return func(*args, **kwargs)
|
|
14
|
+
except sqlite3.Error as e:
|
|
15
|
+
raise RepositoryError(
|
|
16
|
+
f"Database error in {func.__name__}: {e}"
|
|
17
|
+
) from e
|
|
18
|
+
|
|
19
|
+
return wrapper
|
|
@@ -1,14 +1,9 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
import sqlite3
|
|
4
|
-
|
|
5
3
|
from ..models.resume import ResumeModel
|
|
6
4
|
from .base import BaseRepository
|
|
7
5
|
|
|
8
6
|
|
|
9
7
|
class ResumesRepository(BaseRepository):
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
def __init__(self, conn: sqlite3.Connection):
|
|
13
|
-
super().__init__(conn)
|
|
14
|
-
self.model = ResumeModel
|
|
8
|
+
__table__ = "resumes"
|
|
9
|
+
model = ResumeModel
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import logging
|
|
4
|
-
import re
|
|
5
4
|
import sqlite3
|
|
6
5
|
from pathlib import Path
|
|
7
6
|
|
|
@@ -17,7 +16,7 @@ def init_db(conn: sqlite3.Connection) -> None:
|
|
|
17
16
|
conn.executescript(
|
|
18
17
|
(QUERIES_PATH / "schema.sql").read_text(encoding="utf-8")
|
|
19
18
|
)
|
|
20
|
-
logger.debug("Database
|
|
19
|
+
logger.debug("Database initialized")
|
|
21
20
|
|
|
22
21
|
|
|
23
22
|
def list_migrations() -> list[str]:
|
|
@@ -34,16 +33,8 @@ def apply_migration(conn: sqlite3.Connection, name: str) -> None:
|
|
|
34
33
|
)
|
|
35
34
|
|
|
36
35
|
|
|
37
|
-
def model2table(o: type) -> str:
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
# y -> ies (если перед y согласная: vacancy -> vacancies)
|
|
43
|
-
if name.endswith("y") and not name.endswith(("ay", "ey", "iy", "oy", "uy")):
|
|
44
|
-
return name[:-1] + "ies"
|
|
45
|
-
# s, x, z, ch, sh -> +es (bus -> buses, match -> matches)
|
|
46
|
-
if name.endswith(("s", "x", "z", "ch", "sh")):
|
|
47
|
-
return name + "es"
|
|
48
|
-
# Обычный случай
|
|
49
|
-
return name + "s"
|
|
36
|
+
# def model2table(o: type) -> str:
|
|
37
|
+
# name: str = o.__name__
|
|
38
|
+
# if name.endswith("Model"):
|
|
39
|
+
# name = name[:-5]
|
|
40
|
+
# return re.sub(r"(?<!^)(?=[A-Z])", "_", name).lower()
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
from ..api.user_agent import generate_android_useragent
|
|
3
4
|
from .attrdict import AttrDict
|
|
4
5
|
from .config import Config, get_config_path
|
|
5
|
-
from .
|
|
6
|
+
from .date import (
|
|
6
7
|
DATETIME_FORMAT,
|
|
7
8
|
parse_api_datetime,
|
|
8
9
|
try_parse_datetime,
|
|
@@ -10,7 +11,6 @@ from .dateutil import (
|
|
|
10
11
|
from .misc import calc_hash, print_err
|
|
11
12
|
from .string import bool2str, list2str, rand_text, shorten
|
|
12
13
|
from .terminal import setup_terminal
|
|
13
|
-
from .user_agent import hh_android_useragent
|
|
14
14
|
|
|
15
15
|
# Add all public symbols to __all__ for consistent import behavior
|
|
16
16
|
__all__ = [
|
|
@@ -25,7 +25,7 @@ __all__ = [
|
|
|
25
25
|
"bool2str",
|
|
26
26
|
"list2str",
|
|
27
27
|
"calc_hash",
|
|
28
|
-
"
|
|
28
|
+
"generate_android_useragent",
|
|
29
29
|
"setup_terminal",
|
|
30
30
|
"print_err",
|
|
31
31
|
]
|
hh_applicant_tool/utils/log.py
CHANGED
|
@@ -38,9 +38,11 @@ class ColorHandler(logging.StreamHandler):
|
|
|
38
38
|
def format(self, record: logging.LogRecord) -> str:
|
|
39
39
|
# Подавляем вывод подробного сообщения об ошибке
|
|
40
40
|
orig_exc_info = record.exc_info
|
|
41
|
+
|
|
41
42
|
# Детали ошибки показываем только при отладке
|
|
42
43
|
if self.level > logging.DEBUG:
|
|
43
44
|
record.exc_info = None
|
|
45
|
+
|
|
44
46
|
message = super().format(record)
|
|
45
47
|
# Обязательно нужно восстановить оригинальное значение или в файловом
|
|
46
48
|
# логе не будет деталей ошибки
|
|
@@ -107,8 +109,9 @@ def setup_logger(
|
|
|
107
109
|
]
|
|
108
110
|
)
|
|
109
111
|
|
|
112
|
+
file_handler.addFilter(redactor)
|
|
113
|
+
|
|
110
114
|
for h in [color_handler, file_handler]:
|
|
111
|
-
h.addFilter(redactor)
|
|
112
115
|
logger.addHandler(h)
|
|
113
116
|
|
|
114
117
|
|
|
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import platform
|
|
4
4
|
import socket
|
|
5
|
-
from datetime import datetime, timedelta
|
|
5
|
+
from datetime import datetime, timedelta, timezone
|
|
6
6
|
from functools import cache
|
|
7
7
|
from importlib.metadata import version
|
|
8
8
|
from logging import getLogger
|
|
@@ -31,7 +31,7 @@ def get_package_version() -> str | None:
|
|
|
31
31
|
|
|
32
32
|
|
|
33
33
|
class ErrorReporter:
|
|
34
|
-
def
|
|
34
|
+
def __build_report(
|
|
35
35
|
self: HHApplicantTool,
|
|
36
36
|
last_report: datetime,
|
|
37
37
|
) -> dict:
|
|
@@ -42,15 +42,16 @@ class ErrorReporter:
|
|
|
42
42
|
fp, last_report, maxlen=10000
|
|
43
43
|
)
|
|
44
44
|
|
|
45
|
-
# Эти данные нужны для воспроизведения ошибок. Среди них ваших
|
|
46
|
-
|
|
45
|
+
# Эти данные нужны для воспроизведения ошибок. Среди них ваших
|
|
46
|
+
# персональных данных нет
|
|
47
|
+
vacancy_contacts = [
|
|
47
48
|
c.to_dict()
|
|
48
|
-
for c in self.storage.
|
|
49
|
+
for c in self.storage.vacancy_contacts.find(
|
|
49
50
|
updated_at__ge=last_report
|
|
50
51
|
)
|
|
51
52
|
][-10000:]
|
|
52
53
|
|
|
53
|
-
for c in
|
|
54
|
+
for c in vacancy_contacts:
|
|
54
55
|
c.pop("id", 0)
|
|
55
56
|
|
|
56
57
|
employers = [
|
|
@@ -106,15 +107,15 @@ class ErrorReporter:
|
|
|
106
107
|
|
|
107
108
|
return dict(
|
|
108
109
|
error_logs=error_logs,
|
|
109
|
-
|
|
110
|
+
vacancy_contacts=vacancy_contacts,
|
|
110
111
|
employers=employers,
|
|
111
112
|
vacancies=vacancies,
|
|
112
113
|
package_version=get_package_version(),
|
|
113
114
|
system_info=system_info,
|
|
114
|
-
report_created=datetime.now(),
|
|
115
|
+
report_created=datetime.now(timezone.utc),
|
|
115
116
|
)
|
|
116
117
|
|
|
117
|
-
def
|
|
118
|
+
def __send_report(self: HHApplicantTool, data: bytes) -> int:
|
|
118
119
|
try:
|
|
119
120
|
r = self.session.post(
|
|
120
121
|
# "http://localhost:8000/report",
|
|
@@ -128,7 +129,7 @@ class ErrorReporter:
|
|
|
128
129
|
# log.error("Network error: %s", e)
|
|
129
130
|
return False
|
|
130
131
|
|
|
131
|
-
def
|
|
132
|
+
def _process_reporting(self):
|
|
132
133
|
# Получаем timestamp последнего репорта
|
|
133
134
|
last_report = datetime.fromtimestamp(
|
|
134
135
|
self.storage.settings.get_value("_last_report", 0)
|
|
@@ -136,12 +137,12 @@ class ErrorReporter:
|
|
|
136
137
|
|
|
137
138
|
if datetime.now() >= last_report + timedelta(hours=72):
|
|
138
139
|
try:
|
|
139
|
-
report_dict = self.
|
|
140
|
+
report_dict = self.__build_report(last_report)
|
|
140
141
|
has_data = any(
|
|
141
142
|
[
|
|
142
143
|
report_dict.get("error_logs"),
|
|
143
144
|
report_dict.get("employers"),
|
|
144
|
-
report_dict.get("
|
|
145
|
+
report_dict.get("vacancy_contacts"),
|
|
145
146
|
report_dict.get("vacancies"),
|
|
146
147
|
]
|
|
147
148
|
)
|
|
@@ -149,7 +150,7 @@ class ErrorReporter:
|
|
|
149
150
|
data = binpack.serialize(report_dict)
|
|
150
151
|
log.debug("Report body size: %d", len(data))
|
|
151
152
|
# print(binpack.deserialize(data))
|
|
152
|
-
if self.
|
|
153
|
+
if self.__send_report(data):
|
|
153
154
|
log.debug("Report was sent")
|
|
154
155
|
else:
|
|
155
156
|
log.debug("Report failed")
|
|
@@ -161,7 +162,7 @@ class ErrorReporter:
|
|
|
161
162
|
|
|
162
163
|
|
|
163
164
|
class VersionChecker:
|
|
164
|
-
def
|
|
165
|
+
def __get_latest_version(self: HHApplicantTool) -> Literal[False] | str:
|
|
165
166
|
try:
|
|
166
167
|
response = self.session.get(
|
|
167
168
|
"https://pypi.org/pypi/hh-applicant-tool/json", timeout=15
|
|
@@ -172,11 +173,11 @@ class VersionChecker:
|
|
|
172
173
|
except requests.RequestException:
|
|
173
174
|
return False
|
|
174
175
|
|
|
175
|
-
def
|
|
176
|
+
def _check_version(self: HHApplicantTool) -> bool:
|
|
176
177
|
if datetime.now().timestamp() >= self.storage.settings.get_value(
|
|
177
178
|
"_next_version_check", 0
|
|
178
179
|
):
|
|
179
|
-
if v := self.
|
|
180
|
+
if v := self.__get_latest_version():
|
|
180
181
|
self.storage.settings.set_value("_latest_version", v)
|
|
181
182
|
self.storage.settings.set_value(
|
|
182
183
|
"_next_version_check", datetime.now() + timedelta(hours=1)
|
|
@@ -210,11 +211,11 @@ class ChatOpenAISupport:
|
|
|
210
211
|
|
|
211
212
|
|
|
212
213
|
class MegaTool(ErrorReporter, VersionChecker, ChatOpenAISupport):
|
|
213
|
-
def
|
|
214
|
+
def _check_system(self: HHApplicantTool):
|
|
214
215
|
if not self.storage.settings.get_value("disable_version_check", False):
|
|
215
|
-
self.
|
|
216
|
+
self._check_version()
|
|
216
217
|
|
|
217
218
|
if self.storage.settings.get_value("send_error_reports", True):
|
|
218
|
-
self.
|
|
219
|
+
self._process_reporting()
|
|
219
220
|
else:
|
|
220
221
|
log.warning("ОТКЛЮЧЕНА ОТПРАВКА СООБЩЕНИЙ ОБ ОШИБКАХ!")
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import base64
|
|
1
2
|
import ctypes
|
|
2
3
|
import platform
|
|
4
|
+
import sys
|
|
3
5
|
|
|
4
6
|
|
|
5
7
|
def setup_terminal() -> None:
|
|
@@ -17,3 +19,14 @@ def setup_terminal() -> None:
|
|
|
17
19
|
# Если что-то пошло не так (старая Windows или нет прав),
|
|
18
20
|
# просто продолжаем работу без цветов
|
|
19
21
|
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def print_kitty_image(data: bytes) -> None:
|
|
25
|
+
# Кодируем весь файл целиком (он уже сжат в PNG)
|
|
26
|
+
b64data = base64.b64encode(data).decode("ascii")
|
|
27
|
+
|
|
28
|
+
# f=100 говорит терминалу: "это PNG, разберись сам с размерами"
|
|
29
|
+
# Нам больше не нужно указывать s=... и v=...
|
|
30
|
+
sys.stdout.write(f"\033_Ga=T,f=100;{b64data}\033\\")
|
|
31
|
+
sys.stdout.flush()
|
|
32
|
+
print()
|