linkfetch 0.1.3__tar.gz → 1.0.0__tar.gz
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.
- {linkfetch-0.1.3 → linkfetch-1.0.0}/PKG-INFO +2 -2
- {linkfetch-0.1.3 → linkfetch-1.0.0}/pyproject.toml +2 -2
- {linkfetch-0.1.3 → linkfetch-1.0.0}/src/linkfetch/cli.py +18 -0
- {linkfetch-0.1.3 → linkfetch-1.0.0}/src/linkfetch/emit.py +24 -1
- {linkfetch-0.1.3 → linkfetch-1.0.0}/src/linkfetch/parse/common.py +3 -0
- linkfetch-1.0.0/tests/test_regressions.py +34 -0
- {linkfetch-0.1.3 → linkfetch-1.0.0}/uv.lock +1 -1
- {linkfetch-0.1.3 → linkfetch-1.0.0}/.github/workflows/release.yml +0 -0
- {linkfetch-0.1.3 → linkfetch-1.0.0}/.github/workflows/test.yml +0 -0
- {linkfetch-0.1.3 → linkfetch-1.0.0}/.gitignore +0 -0
- {linkfetch-0.1.3 → linkfetch-1.0.0}/LICENSE +0 -0
- {linkfetch-0.1.3 → linkfetch-1.0.0}/README.md +0 -0
- {linkfetch-0.1.3 → linkfetch-1.0.0}/config.toml +0 -0
- {linkfetch-0.1.3 → linkfetch-1.0.0}/src/linkfetch/__init__.py +0 -0
- {linkfetch-0.1.3 → linkfetch-1.0.0}/src/linkfetch/capture/__init__.py +0 -0
- {linkfetch-0.1.3 → linkfetch-1.0.0}/src/linkfetch/capture/browser.py +0 -0
- {linkfetch-0.1.3 → linkfetch-1.0.0}/src/linkfetch/capture/expand.py +0 -0
- {linkfetch-0.1.3 → linkfetch-1.0.0}/src/linkfetch/config.py +0 -0
- {linkfetch-0.1.3 → linkfetch-1.0.0}/src/linkfetch/models.py +0 -0
- {linkfetch-0.1.3 → linkfetch-1.0.0}/src/linkfetch/parse/__init__.py +0 -0
- {linkfetch-0.1.3 → linkfetch-1.0.0}/src/linkfetch/parse/awards.py +0 -0
- {linkfetch-0.1.3 → linkfetch-1.0.0}/src/linkfetch/parse/basics.py +0 -0
- {linkfetch-0.1.3 → linkfetch-1.0.0}/src/linkfetch/parse/certifications.py +0 -0
- {linkfetch-0.1.3 → linkfetch-1.0.0}/src/linkfetch/parse/courses.py +0 -0
- {linkfetch-0.1.3 → linkfetch-1.0.0}/src/linkfetch/parse/education.py +0 -0
- {linkfetch-0.1.3 → linkfetch-1.0.0}/src/linkfetch/parse/experience.py +0 -0
- {linkfetch-0.1.3 → linkfetch-1.0.0}/src/linkfetch/parse/honors.py +0 -0
- {linkfetch-0.1.3 → linkfetch-1.0.0}/src/linkfetch/parse/languages.py +0 -0
- {linkfetch-0.1.3 → linkfetch-1.0.0}/src/linkfetch/parse/patents.py +0 -0
- {linkfetch-0.1.3 → linkfetch-1.0.0}/src/linkfetch/parse/projects.py +0 -0
- {linkfetch-0.1.3 → linkfetch-1.0.0}/src/linkfetch/parse/recommendations.py +0 -0
- {linkfetch-0.1.3 → linkfetch-1.0.0}/src/linkfetch/parse/skills.py +0 -0
- {linkfetch-0.1.3 → linkfetch-1.0.0}/src/linkfetch/parse/volunteering.py +0 -0
- {linkfetch-0.1.3 → linkfetch-1.0.0}/src/linkfetch/sections.py +0 -0
- {linkfetch-0.1.3 → linkfetch-1.0.0}/src/linkfetch/text.py +0 -0
- {linkfetch-0.1.3 → linkfetch-1.0.0}/tests/conftest.py +0 -0
- {linkfetch-0.1.3 → linkfetch-1.0.0}/tests/fixtures/basics.html +0 -0
- {linkfetch-0.1.3 → linkfetch-1.0.0}/tests/fixtures/certifications.html +0 -0
- {linkfetch-0.1.3 → linkfetch-1.0.0}/tests/fixtures/education.html +0 -0
- {linkfetch-0.1.3 → linkfetch-1.0.0}/tests/fixtures/experience.html +0 -0
- {linkfetch-0.1.3 → linkfetch-1.0.0}/tests/fixtures/honors.html +0 -0
- {linkfetch-0.1.3 → linkfetch-1.0.0}/tests/fixtures/languages.html +0 -0
- {linkfetch-0.1.3 → linkfetch-1.0.0}/tests/fixtures/patents.html +0 -0
- {linkfetch-0.1.3 → linkfetch-1.0.0}/tests/fixtures/projects.html +0 -0
- {linkfetch-0.1.3 → linkfetch-1.0.0}/tests/fixtures/recommendations.html +0 -0
- {linkfetch-0.1.3 → linkfetch-1.0.0}/tests/fixtures/skills.html +0 -0
- {linkfetch-0.1.3 → linkfetch-1.0.0}/tests/fixtures/volunteering.html +0 -0
- {linkfetch-0.1.3 → linkfetch-1.0.0}/tests/test_capture_retry.py +0 -0
- {linkfetch-0.1.3 → linkfetch-1.0.0}/tests/test_config.py +0 -0
- {linkfetch-0.1.3 → linkfetch-1.0.0}/tests/test_emit.py +0 -0
- {linkfetch-0.1.3 → linkfetch-1.0.0}/tests/test_experience.py +0 -0
- {linkfetch-0.1.3 → linkfetch-1.0.0}/tests/test_release.py +0 -0
- {linkfetch-0.1.3 → linkfetch-1.0.0}/tests/test_sections.py +0 -0
- {linkfetch-0.1.3 → linkfetch-1.0.0}/tests/test_text.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: linkfetch
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 1.0.0
|
|
4
4
|
Summary: Capture your own LinkedIn profile on your own computer and turn it into structured profile files
|
|
5
5
|
Project-URL: Homepage, https://github.com/Prosperis/linkfetch
|
|
6
6
|
Project-URL: Issues, https://github.com/Prosperis/linkfetch/issues
|
|
@@ -8,7 +8,7 @@ Author: Prosperis
|
|
|
8
8
|
License-Expression: MIT
|
|
9
9
|
License-File: LICENSE
|
|
10
10
|
Keywords: cv,export,linkedin,playwright,profile,resume
|
|
11
|
-
Classifier: Development Status ::
|
|
11
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
12
12
|
Classifier: Environment :: Console
|
|
13
13
|
Classifier: Intended Audience :: End Users/Desktop
|
|
14
14
|
Classifier: Operating System :: OS Independent
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "linkfetch"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "1.0.0"
|
|
4
4
|
description = "Capture your own LinkedIn profile on your own computer and turn it into structured profile files"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.10"
|
|
@@ -9,7 +9,7 @@ license-files = ["LICENSE"]
|
|
|
9
9
|
authors = [{ name = "Prosperis" }]
|
|
10
10
|
keywords = ["linkedin", "profile", "resume", "cv", "export", "playwright"]
|
|
11
11
|
classifiers = [
|
|
12
|
-
"Development Status ::
|
|
12
|
+
"Development Status :: 5 - Production/Stable",
|
|
13
13
|
"Environment :: Console",
|
|
14
14
|
"Intended Audience :: End Users/Desktop",
|
|
15
15
|
"Operating System :: OS Independent",
|
|
@@ -52,6 +52,20 @@ def _playwright_installed() -> bool:
|
|
|
52
52
|
return importlib.util.find_spec("playwright") is not None
|
|
53
53
|
|
|
54
54
|
|
|
55
|
+
def _browser_downloaded() -> bool:
|
|
56
|
+
"""True if the Chromium build this Playwright version drives is on disk.
|
|
57
|
+
|
|
58
|
+
Starts Playwright's driver only to ask for the path; no browser opens.
|
|
59
|
+
"""
|
|
60
|
+
try:
|
|
61
|
+
from playwright.sync_api import sync_playwright
|
|
62
|
+
|
|
63
|
+
with sync_playwright() as p:
|
|
64
|
+
return Path(p.chromium.executable_path).is_file()
|
|
65
|
+
except Exception:
|
|
66
|
+
return False
|
|
67
|
+
|
|
68
|
+
|
|
55
69
|
def _section_arg(sections: list[str] | None) -> list[Section]:
|
|
56
70
|
try:
|
|
57
71
|
return resolve(sections)
|
|
@@ -76,6 +90,10 @@ def doctor() -> None:
|
|
|
76
90
|
|
|
77
91
|
if _playwright_installed():
|
|
78
92
|
console.print("[green]✓[/] Playwright is installed")
|
|
93
|
+
if _browser_downloaded():
|
|
94
|
+
console.print("[green]✓[/] The browser is downloaded")
|
|
95
|
+
else:
|
|
96
|
+
console.print("[yellow]![/] The browser is not downloaded yet — run `linkfetch install-browser`")
|
|
79
97
|
else:
|
|
80
98
|
console.print(
|
|
81
99
|
"[yellow]![/] Playwright not installed — capture/login unavailable.\n"
|
|
@@ -24,7 +24,30 @@ def _to_plain(models: list[BaseModel] | BaseModel) -> object:
|
|
|
24
24
|
"""
|
|
25
25
|
if isinstance(models, BaseModel):
|
|
26
26
|
return _prune(models.model_dump())
|
|
27
|
-
return [_prune(m.model_dump()) for m in models]
|
|
27
|
+
return _unique([_prune(m.model_dump()) for m in models])
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _unique(items: list[dict]) -> list[dict]:
|
|
31
|
+
"""Drop exact duplicates and keep ``id`` values unique.
|
|
32
|
+
|
|
33
|
+
LinkedIn can render the same entry twice (a lazy-loaded list re-rendering,
|
|
34
|
+
or an entry genuinely listed twice); an identical copy adds nothing. Two
|
|
35
|
+
*different* entries that slug to the same ``id`` both stay, the later one
|
|
36
|
+
suffixed ``-2``, ``-3`` … so ids remain unique within a file.
|
|
37
|
+
"""
|
|
38
|
+
out: list[dict] = []
|
|
39
|
+
seen_ids: dict[str, int] = {}
|
|
40
|
+
for item in items:
|
|
41
|
+
if item in out:
|
|
42
|
+
continue
|
|
43
|
+
base = item.get("id")
|
|
44
|
+
if isinstance(base, str):
|
|
45
|
+
count = seen_ids.get(base, 0) + 1
|
|
46
|
+
seen_ids[base] = count
|
|
47
|
+
if count > 1:
|
|
48
|
+
item = {**item, "id": f"{base}-{count}"}
|
|
49
|
+
out.append(item)
|
|
50
|
+
return out
|
|
28
51
|
|
|
29
52
|
|
|
30
53
|
def _prune(value: object) -> object:
|
|
@@ -44,6 +44,9 @@ _CHROME = (
|
|
|
44
44
|
"select language", "profile language", "accessibility", "talent solutions",
|
|
45
45
|
"community guidelines", "marketing solutions", "privacy & terms",
|
|
46
46
|
"ad choices", "sales solutions", "small business", "safety center",
|
|
47
|
+
# In-section notices, e.g. "Projects from connected apps sync
|
|
48
|
+
# automatically and can't be edited on LinkedIn."
|
|
49
|
+
"sync automatically", "be edited on linkedin",
|
|
47
50
|
)
|
|
48
51
|
|
|
49
52
|
# Section-title fragments that appear as the first child block of the content
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Bugs found in a real capture, kept fixed."""
|
|
2
|
+
|
|
3
|
+
from linkfetch.emit import _to_plain
|
|
4
|
+
from linkfetch.models import Course
|
|
5
|
+
from linkfetch.parse import projects
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _page(*entries: str) -> str:
|
|
9
|
+
return (
|
|
10
|
+
"<html><body><main><section aria-label='Primary content'>"
|
|
11
|
+
"<div><p>Projects</p></div><div>" + "".join(entries) + "</div>"
|
|
12
|
+
"</section></main></body></html>"
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_linkedin_notice_is_not_a_project():
|
|
17
|
+
html = _page(
|
|
18
|
+
"<div><p>Projects from connected apps sync automatically and can’t be edited on LinkedIn.</p>"
|
|
19
|
+
"<span>Learn more</span></div>",
|
|
20
|
+
"<div><p>Example Viewer</p><p>Nov 2025 – Present</p></div>",
|
|
21
|
+
"<div><p>Example CLI</p><p>Jan 2024 – Mar 2024</p></div>",
|
|
22
|
+
)
|
|
23
|
+
assert [p.name for p in projects.parse(html)] == ["Example Viewer", "Example CLI"]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_identical_entries_are_written_once_and_ids_stay_unique():
|
|
27
|
+
data = _to_plain([
|
|
28
|
+
Course(id="stats-math-32", name="Probability and Statistics", number="MATH 32"),
|
|
29
|
+
Course(id="stats-math-32", name="Probability and Statistics", number="MATH 32"),
|
|
30
|
+
Course(id="intro", name="Intro", associated_with="School A"),
|
|
31
|
+
Course(id="intro", name="Intro", associated_with="School B"),
|
|
32
|
+
])
|
|
33
|
+
assert [c["id"] for c in data] == ["stats-math-32", "intro", "intro-2"]
|
|
34
|
+
assert data[2]["associated_with"] == "School B"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|