linkedin-apply-assistant 0.1.1
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.
- package/.github/ISSUE_TEMPLATE/bug_report.yml +72 -0
- package/.github/ISSUE_TEMPLATE/config.yml +5 -0
- package/.github/ISSUE_TEMPLATE/config_help.yml +49 -0
- package/.github/ISSUE_TEMPLATE/docs.yml +40 -0
- package/.github/ISSUE_TEMPLATE/feature_request.yml +45 -0
- package/.github/ISSUE_TEMPLATE/safety_compliance.yml +48 -0
- package/.github/PULL_REQUEST_TEMPLATE.md +43 -0
- package/CHANGELOG.md +47 -0
- package/CODE_OF_CONDUCT.md +47 -0
- package/CONTRIBUTING.md +64 -0
- package/GOVERNANCE.md +41 -0
- package/LEGAL.md +38 -0
- package/LICENSE +22 -0
- package/MIGRATION.md +50 -0
- package/README.md +167 -0
- package/RELEASE_CHECKLIST.md +454 -0
- package/SAFETY.md +33 -0
- package/SECURITY.md +37 -0
- package/SUPPORT.md +44 -0
- package/THIRD_PARTY_NOTICES.md +67 -0
- package/bin/linkedin-apply-assistant.mjs +95 -0
- package/configs/config.example.yml +24 -0
- package/configs/qa_bank.example.yml +35 -0
- package/docs/apply.md +40 -0
- package/docs/assist.md +35 -0
- package/docs/browser-session.md +45 -0
- package/docs/ci-and-release-policy.md +105 -0
- package/docs/commands.md +176 -0
- package/docs/install-and-configuration.md +265 -0
- package/docs/registry-publication-strategy.md +169 -0
- package/docs/reports.md +35 -0
- package/docs/search.md +39 -0
- package/docs/troubleshooting.md +57 -0
- package/examples/dry_run_input.example.json +25 -0
- package/examples/reports/apply-audit.example.json +31 -0
- package/examples/reports/search-report.example.json +40 -0
- package/install.ps1 +178 -0
- package/package.json +59 -0
- package/pyproject.toml +51 -0
- package/src/linkedin_apply_assistant/__init__.py +8 -0
- package/src/linkedin_apply_assistant/apply_reports.py +229 -0
- package/src/linkedin_apply_assistant/ats_handlers.py +217 -0
- package/src/linkedin_apply_assistant/browser_sessions.py +155 -0
- package/src/linkedin_apply_assistant/cli.py +570 -0
- package/src/linkedin_apply_assistant/config.py +109 -0
- package/src/linkedin_apply_assistant/contracts.py +255 -0
- package/src/linkedin_apply_assistant/form_engine.py +180 -0
- package/src/linkedin_apply_assistant/linkedin_layer.py +436 -0
- package/src/linkedin_apply_assistant/page_actions.py +110 -0
- package/src/linkedin_apply_assistant/page_selectors.py +88 -0
- package/src/linkedin_apply_assistant/paths.py +135 -0
- package/src/linkedin_apply_assistant/qa_bank.py +352 -0
- package/src/linkedin_apply_assistant/redaction.py +119 -0
- package/src/linkedin_apply_assistant/safety.py +230 -0
- package/src/linkedin_apply_assistant/workflows.py +435 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# Troubleshooting
|
|
2
|
+
|
|
3
|
+
Start with the command help:
|
|
4
|
+
|
|
5
|
+
```powershell
|
|
6
|
+
linkedin-apply-assistant --help
|
|
7
|
+
linkedin-apply-assistant config check
|
|
8
|
+
linkedin-apply-assistant config check --help
|
|
9
|
+
linkedin-apply-assistant search --help
|
|
10
|
+
linkedin-apply-assistant assist --help
|
|
11
|
+
linkedin-apply-assistant apply --help
|
|
12
|
+
linkedin-apply-assistant dry-run --help
|
|
13
|
+
linkedin-apply-assistant report --help
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
For normal command examples, resolved output paths, reports, browser profile location, and first-run setup, read the [command reference](commands.md).
|
|
17
|
+
|
|
18
|
+
## Command Not Found
|
|
19
|
+
|
|
20
|
+
Install the package in editable mode:
|
|
21
|
+
|
|
22
|
+
```powershell
|
|
23
|
+
python -m pip install -e ".[dev]"
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Or use the module fallback:
|
|
27
|
+
|
|
28
|
+
```powershell
|
|
29
|
+
$env:PYTHONPATH=(Resolve-Path 'src').Path
|
|
30
|
+
python -m linkedin_apply_assistant.cli --help
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Browser Does Not Open
|
|
34
|
+
|
|
35
|
+
- Confirm Playwright is installed with the package dependencies.
|
|
36
|
+
- Install Chromium with `python -m playwright install chromium`.
|
|
37
|
+
- Run `linkedin-apply-assistant config check` to inspect the resolved browser profile path.
|
|
38
|
+
- Use `--browser-profile` with a local ignored directory.
|
|
39
|
+
- Avoid copying browser profile directories between machines or into public artifacts.
|
|
40
|
+
|
|
41
|
+
## Unknown Required Questions
|
|
42
|
+
|
|
43
|
+
Update your local Q&A bank from [../configs/qa_bank.example.yml](../configs/qa_bank.example.yml). Unknown required questions should pause the workflow until you can provide a truthful answer.
|
|
44
|
+
|
|
45
|
+
## Selector Drift
|
|
46
|
+
|
|
47
|
+
LinkedIn and ATS pages can change. If a visible-browser workflow stops detecting fields, reduce scope to `dry-run` or `report`, capture a sanitized description, and add or update tests before changing selectors.
|
|
48
|
+
|
|
49
|
+
## Quality Gate Failure
|
|
50
|
+
|
|
51
|
+
Run the package quality gate from the package root:
|
|
52
|
+
|
|
53
|
+
```powershell
|
|
54
|
+
python scripts\quality.py
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
The gate runs compile checks, package pytest, Ruff check, Ruff format check, dependency audit, docs smoke tests, privacy scans, and release-readiness checks.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"jobs": [
|
|
3
|
+
{
|
|
4
|
+
"title": "Backend Platform Engineer",
|
|
5
|
+
"company": "Example Systems",
|
|
6
|
+
"url": "https://example.com/jobs/backend-platform-engineer",
|
|
7
|
+
"location": "Remote",
|
|
8
|
+
"description": "Build and maintain service APIs, data workflows, and reliability tooling for a small product team."
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
"title": "Frontend Application Engineer",
|
|
12
|
+
"company": "Sample Interface Labs",
|
|
13
|
+
"url": "https://example.com/jobs/frontend-application-engineer",
|
|
14
|
+
"location": "Example City",
|
|
15
|
+
"description": "Develop accessible application screens, reusable components, and browser-based workflow improvements."
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"title": "Automation QA Engineer",
|
|
19
|
+
"company": "Demo Quality Works",
|
|
20
|
+
"url": "https://example.com/jobs/automation-qa-engineer",
|
|
21
|
+
"location": "Hybrid",
|
|
22
|
+
"description": "Create deterministic test fixtures, validate user-facing flows, and improve local developer feedback loops."
|
|
23
|
+
}
|
|
24
|
+
]
|
|
25
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema_version": "example-1",
|
|
3
|
+
"command": "apply",
|
|
4
|
+
"generated_at": "2026-06-08T00:00:00Z",
|
|
5
|
+
"status": "prepared",
|
|
6
|
+
"summary": {
|
|
7
|
+
"jobs_loaded": 1,
|
|
8
|
+
"jobs_prepared": 1,
|
|
9
|
+
"applications_submitted": 0,
|
|
10
|
+
"unknown_required_questions": 1
|
|
11
|
+
},
|
|
12
|
+
"jobs": [
|
|
13
|
+
{
|
|
14
|
+
"company": "Example Robotics",
|
|
15
|
+
"role": "Applied AI Engineer",
|
|
16
|
+
"decision": "needs-review",
|
|
17
|
+
"submit_state": "disabled",
|
|
18
|
+
"blockers": [
|
|
19
|
+
"One required question needs a truthful user answer before completion."
|
|
20
|
+
]
|
|
21
|
+
}
|
|
22
|
+
],
|
|
23
|
+
"privacy": {
|
|
24
|
+
"synthetic": true,
|
|
25
|
+
"contains_browser_state": false,
|
|
26
|
+
"contains_private_documents": false,
|
|
27
|
+
"contains_full_private_urls": false,
|
|
28
|
+
"contains_screenshots": false
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema_version": "example-1",
|
|
3
|
+
"command": "search",
|
|
4
|
+
"generated_at": "2026-06-08T00:00:00Z",
|
|
5
|
+
"status": "completed",
|
|
6
|
+
"query": {
|
|
7
|
+
"text": "applied ai engineer",
|
|
8
|
+
"location": "Remote",
|
|
9
|
+
"limit": 2
|
|
10
|
+
},
|
|
11
|
+
"summary": {
|
|
12
|
+
"jobs_seen": 2,
|
|
13
|
+
"jobs_recorded": 2,
|
|
14
|
+
"applications_submitted": 0,
|
|
15
|
+
"blocked": 0
|
|
16
|
+
},
|
|
17
|
+
"jobs": [
|
|
18
|
+
{
|
|
19
|
+
"company": "Example Robotics",
|
|
20
|
+
"role": "Applied AI Engineer",
|
|
21
|
+
"source": "linkedin-search",
|
|
22
|
+
"status": "recorded",
|
|
23
|
+
"public_domain": "example.test"
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"company": "Sample Automation Lab",
|
|
27
|
+
"role": "Workflow Automation Engineer",
|
|
28
|
+
"source": "linkedin-search",
|
|
29
|
+
"status": "recorded",
|
|
30
|
+
"public_domain": "example.test"
|
|
31
|
+
}
|
|
32
|
+
],
|
|
33
|
+
"privacy": {
|
|
34
|
+
"synthetic": true,
|
|
35
|
+
"contains_browser_state": false,
|
|
36
|
+
"contains_private_documents": false,
|
|
37
|
+
"contains_full_private_urls": false
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
package/install.ps1
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
[CmdletBinding()]
|
|
2
|
+
param(
|
|
3
|
+
[string]$InstallDir = (Join-Path $env:LOCALAPPDATA "linkedin-apply-assistant"),
|
|
4
|
+
[string]$Ref = "main",
|
|
5
|
+
[switch]$InstallBrowser,
|
|
6
|
+
[switch]$NoPath
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
Set-StrictMode -Version 3.0
|
|
10
|
+
$ErrorActionPreference = "Stop"
|
|
11
|
+
|
|
12
|
+
$RepoOwner = "MohammedGhazal09"
|
|
13
|
+
$RepoName = "linkedin-apply-assistant"
|
|
14
|
+
|
|
15
|
+
function Write-Step {
|
|
16
|
+
param([string]$Message)
|
|
17
|
+
Write-Host "[linkedin-apply-assistant] $Message"
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function Get-ArchiveUrl {
|
|
21
|
+
param([string]$SourceRef)
|
|
22
|
+
|
|
23
|
+
if ($SourceRef -match "^refs/heads/") {
|
|
24
|
+
return "https://github.com/$RepoOwner/$RepoName/archive/$SourceRef.zip"
|
|
25
|
+
}
|
|
26
|
+
if ($SourceRef -match "^refs/tags/") {
|
|
27
|
+
return "https://github.com/$RepoOwner/$RepoName/archive/$SourceRef.zip"
|
|
28
|
+
}
|
|
29
|
+
if ($SourceRef -match "^v?\d+\.\d+\.\d+") {
|
|
30
|
+
return "https://github.com/$RepoOwner/$RepoName/archive/refs/tags/$SourceRef.zip"
|
|
31
|
+
}
|
|
32
|
+
return "https://github.com/$RepoOwner/$RepoName/archive/refs/heads/$SourceRef.zip"
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function Get-Python {
|
|
36
|
+
$candidates = @(
|
|
37
|
+
@{ Command = "py"; Args = @("-3.11") },
|
|
38
|
+
@{ Command = "py"; Args = @("-3") },
|
|
39
|
+
@{ Command = "python"; Args = @() },
|
|
40
|
+
@{ Command = "python3"; Args = @() }
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
foreach ($candidate in $candidates) {
|
|
44
|
+
$command = $candidate.Command
|
|
45
|
+
$prefixArgs = [string[]]$candidate.Args
|
|
46
|
+
try {
|
|
47
|
+
& $command @prefixArgs -c "import sys; raise SystemExit(0 if sys.version_info >= (3, 11) else 1)" *> $null
|
|
48
|
+
if ($LASTEXITCODE -eq 0) {
|
|
49
|
+
return $candidate
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
continue
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
throw "Python 3.11 or newer was not found. Install Python 3.11+ and rerun this script."
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function Invoke-Python {
|
|
61
|
+
param(
|
|
62
|
+
[hashtable]$Python,
|
|
63
|
+
[string[]]$Arguments
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
& $Python.Command @([string[]]$Python.Args + $Arguments)
|
|
67
|
+
if ($LASTEXITCODE -ne 0) {
|
|
68
|
+
throw "Python command failed: $($Python.Command) $($Arguments -join ' ')"
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function Add-UserPathEntry {
|
|
73
|
+
param([string]$PathEntry)
|
|
74
|
+
|
|
75
|
+
$current = [Environment]::GetEnvironmentVariable("Path", "User")
|
|
76
|
+
$parts = @()
|
|
77
|
+
if ($current) {
|
|
78
|
+
$parts = $current -split [IO.Path]::PathSeparator
|
|
79
|
+
}
|
|
80
|
+
if ($parts -contains $PathEntry) {
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
$next = if ($current) {
|
|
85
|
+
"$PathEntry$([IO.Path]::PathSeparator)$current"
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
$PathEntry
|
|
89
|
+
}
|
|
90
|
+
[Environment]::SetEnvironmentVariable("Path", $next, "User")
|
|
91
|
+
|
|
92
|
+
if (($env:Path -split [IO.Path]::PathSeparator) -notcontains $PathEntry) {
|
|
93
|
+
$env:Path = "$PathEntry$([IO.Path]::PathSeparator)$env:Path"
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
$python = Get-Python
|
|
98
|
+
$archiveUrl = Get-ArchiveUrl -SourceRef $Ref
|
|
99
|
+
$installRoot = [IO.Path]::GetFullPath($InstallDir)
|
|
100
|
+
$sourceDir = Join-Path $installRoot "source"
|
|
101
|
+
$venvDir = Join-Path $installRoot ".venv"
|
|
102
|
+
$binDir = Join-Path $installRoot "bin"
|
|
103
|
+
$tempDir = Join-Path ([IO.Path]::GetTempPath()) ("linkedin-apply-assistant-" + [guid]::NewGuid())
|
|
104
|
+
$zipPath = Join-Path $tempDir "source.zip"
|
|
105
|
+
|
|
106
|
+
try {
|
|
107
|
+
Write-Step "Installing from $archiveUrl"
|
|
108
|
+
New-Item -ItemType Directory -Force -Path $tempDir | Out-Null
|
|
109
|
+
New-Item -ItemType Directory -Force -Path $installRoot | Out-Null
|
|
110
|
+
|
|
111
|
+
Invoke-WebRequest -UseBasicParsing -Uri $archiveUrl -OutFile $zipPath
|
|
112
|
+
Expand-Archive -LiteralPath $zipPath -DestinationPath $tempDir -Force
|
|
113
|
+
|
|
114
|
+
$expanded = Get-ChildItem -LiteralPath $tempDir -Directory |
|
|
115
|
+
Where-Object { $_.Name -like "$RepoName-*" } |
|
|
116
|
+
Select-Object -First 1
|
|
117
|
+
if ($null -eq $expanded) {
|
|
118
|
+
throw "Could not find extracted package directory in $tempDir"
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (Test-Path -LiteralPath $sourceDir) {
|
|
122
|
+
Remove-Item -LiteralPath $sourceDir -Recurse -Force
|
|
123
|
+
}
|
|
124
|
+
Move-Item -LiteralPath $expanded.FullName -Destination $sourceDir
|
|
125
|
+
|
|
126
|
+
Write-Step "Creating virtual environment"
|
|
127
|
+
Invoke-Python -Python $python -Arguments @("-m", "venv", $venvDir)
|
|
128
|
+
|
|
129
|
+
$venvPython = Join-Path $venvDir "Scripts\python.exe"
|
|
130
|
+
if (-not (Test-Path -LiteralPath $venvPython)) {
|
|
131
|
+
throw "Virtual environment Python was not created at $venvPython"
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
Write-Step "Installing Python package"
|
|
135
|
+
& $venvPython -m pip install --upgrade pip
|
|
136
|
+
if ($LASTEXITCODE -ne 0) { throw "pip upgrade failed" }
|
|
137
|
+
& $venvPython -m pip install $sourceDir
|
|
138
|
+
if ($LASTEXITCODE -ne 0) { throw "Package install failed" }
|
|
139
|
+
|
|
140
|
+
if ($InstallBrowser) {
|
|
141
|
+
Write-Step "Installing Playwright Chromium"
|
|
142
|
+
& $venvPython -m playwright install chromium
|
|
143
|
+
if ($LASTEXITCODE -ne 0) { throw "Playwright Chromium install failed" }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
New-Item -ItemType Directory -Force -Path $binDir | Out-Null
|
|
147
|
+
$psShim = Join-Path $binDir "linkedin-apply-assistant.ps1"
|
|
148
|
+
$cmdShim = Join-Path $binDir "linkedin-apply-assistant.cmd"
|
|
149
|
+
|
|
150
|
+
@"
|
|
151
|
+
param([Parameter(ValueFromRemainingArguments = `$true)][string[]]`$RemainingArgs)
|
|
152
|
+
& "$venvPython" -m linkedin_apply_assistant.cli @RemainingArgs
|
|
153
|
+
exit `$LASTEXITCODE
|
|
154
|
+
"@ | Set-Content -LiteralPath $psShim -Encoding UTF8
|
|
155
|
+
|
|
156
|
+
"@echo off`r`n`"$venvPython`" -m linkedin_apply_assistant.cli %*`r`n" |
|
|
157
|
+
Set-Content -LiteralPath $cmdShim -Encoding ASCII
|
|
158
|
+
|
|
159
|
+
if (-not $NoPath) {
|
|
160
|
+
Add-UserPathEntry -PathEntry $binDir
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
Write-Step "Installed to $installRoot"
|
|
164
|
+
Write-Host ""
|
|
165
|
+
Write-Host "Try it now:"
|
|
166
|
+
Write-Host " linkedin-apply-assistant --help"
|
|
167
|
+
Write-Host " linkedin-apply-assistant config check"
|
|
168
|
+
if (-not $InstallBrowser) {
|
|
169
|
+
Write-Host ""
|
|
170
|
+
Write-Host "For visible-browser workflows, install Chromium later with:"
|
|
171
|
+
Write-Host " & `"$venvPython`" -m playwright install chromium"
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
finally {
|
|
175
|
+
if (Test-Path -LiteralPath $tempDir) {
|
|
176
|
+
Remove-Item -LiteralPath $tempDir -Recurse -Force
|
|
177
|
+
}
|
|
178
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "linkedin-apply-assistant",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Thin npm launcher for the Python LinkedIn apply assistant package",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/MohammedGhazal09/linkedin-apply-assistant.git"
|
|
9
|
+
},
|
|
10
|
+
"homepage": "https://github.com/MohammedGhazal09/linkedin-apply-assistant#readme",
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/MohammedGhazal09/linkedin-apply-assistant/issues"
|
|
13
|
+
},
|
|
14
|
+
"type": "module",
|
|
15
|
+
"bin": {
|
|
16
|
+
"linkedin-apply-assistant": "bin/linkedin-apply-assistant.mjs"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"bin/linkedin-apply-assistant.mjs",
|
|
20
|
+
"pyproject.toml",
|
|
21
|
+
"src/",
|
|
22
|
+
"install.ps1",
|
|
23
|
+
"README.md",
|
|
24
|
+
"SAFETY.md",
|
|
25
|
+
"LEGAL.md",
|
|
26
|
+
"LICENSE",
|
|
27
|
+
"THIRD_PARTY_NOTICES.md",
|
|
28
|
+
"CHANGELOG.md",
|
|
29
|
+
"MIGRATION.md",
|
|
30
|
+
"CONTRIBUTING.md",
|
|
31
|
+
"SECURITY.md",
|
|
32
|
+
"SUPPORT.md",
|
|
33
|
+
"GOVERNANCE.md",
|
|
34
|
+
"CODE_OF_CONDUCT.md",
|
|
35
|
+
".github/ISSUE_TEMPLATE/bug_report.yml",
|
|
36
|
+
".github/ISSUE_TEMPLATE/feature_request.yml",
|
|
37
|
+
".github/ISSUE_TEMPLATE/docs.yml",
|
|
38
|
+
".github/ISSUE_TEMPLATE/safety_compliance.yml",
|
|
39
|
+
".github/ISSUE_TEMPLATE/config_help.yml",
|
|
40
|
+
".github/ISSUE_TEMPLATE/config.yml",
|
|
41
|
+
".github/PULL_REQUEST_TEMPLATE.md",
|
|
42
|
+
"RELEASE_CHECKLIST.md",
|
|
43
|
+
"docs/install-and-configuration.md",
|
|
44
|
+
"docs/registry-publication-strategy.md",
|
|
45
|
+
"docs/commands.md",
|
|
46
|
+
"docs/browser-session.md",
|
|
47
|
+
"docs/search.md",
|
|
48
|
+
"docs/assist.md",
|
|
49
|
+
"docs/apply.md",
|
|
50
|
+
"docs/ci-and-release-policy.md",
|
|
51
|
+
"docs/reports.md",
|
|
52
|
+
"docs/troubleshooting.md",
|
|
53
|
+
"configs/config.example.yml",
|
|
54
|
+
"configs/qa_bank.example.yml",
|
|
55
|
+
"examples/dry_run_input.example.json",
|
|
56
|
+
"examples/reports/search-report.example.json",
|
|
57
|
+
"examples/reports/apply-audit.example.json"
|
|
58
|
+
]
|
|
59
|
+
}
|
package/pyproject.toml
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "linkedin-apply-assistant"
|
|
7
|
+
version = "0.1.1"
|
|
8
|
+
description = "Local LinkedIn application assistant with user-visible browser workflows"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"playwright>=1.40",
|
|
13
|
+
"scrapling>=0.2.0",
|
|
14
|
+
"PyYAML>=6.0",
|
|
15
|
+
"platformdirs>=4.0",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[project.optional-dependencies]
|
|
19
|
+
test = ["pytest>=9.0.3"]
|
|
20
|
+
dev = [
|
|
21
|
+
"build>=1.0",
|
|
22
|
+
"pip-audit>=2.7",
|
|
23
|
+
"pytest>=9.0.3",
|
|
24
|
+
"ruff>=0.6",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[project.scripts]
|
|
28
|
+
linkedin-apply-assistant = "linkedin_apply_assistant.cli:main"
|
|
29
|
+
|
|
30
|
+
[project.urls]
|
|
31
|
+
Homepage = "https://github.com/MohammedGhazal09/linkedin-apply-assistant#readme"
|
|
32
|
+
Repository = "https://github.com/MohammedGhazal09/linkedin-apply-assistant"
|
|
33
|
+
Issues = "https://github.com/MohammedGhazal09/linkedin-apply-assistant/issues"
|
|
34
|
+
|
|
35
|
+
[tool.setuptools.packages.find]
|
|
36
|
+
where = ["src"]
|
|
37
|
+
|
|
38
|
+
[tool.pytest.ini_options]
|
|
39
|
+
addopts = "-p no:cacheprovider"
|
|
40
|
+
markers = [
|
|
41
|
+
"live: opt-in tests that touch live services, real browser profiles, or network targets",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
[tool.ruff]
|
|
45
|
+
target-version = "py311"
|
|
46
|
+
line-length = 100
|
|
47
|
+
|
|
48
|
+
[tool.ruff.lint.per-file-ignores]
|
|
49
|
+
"tests/test_cli_contract.py" = ["E402"]
|
|
50
|
+
"tests/test_runtime_boundaries.py" = ["E402"]
|
|
51
|
+
"tests/test_runtime_decoupling.py" = ["E402"]
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Package identity for the standalone LinkedIn application assistant."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.1.1"
|
|
4
|
+
|
|
5
|
+
APP_DISPLAY_NAME = "LinkedIn-apply-assistant"
|
|
6
|
+
APP_PACKAGE_NAME = "linkedin-apply-assistant"
|
|
7
|
+
APP_IMPORT_NAME = "linkedin_apply_assistant"
|
|
8
|
+
APP_COMMAND_NAME = "linkedin-apply-assistant"
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""Package-local report writers for standalone runtime paths."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
import json
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import re
|
|
9
|
+
from typing import Any
|
|
10
|
+
from uuid import uuid4
|
|
11
|
+
|
|
12
|
+
from .contracts import ReportArtifact
|
|
13
|
+
from .paths import RuntimePaths
|
|
14
|
+
from .redaction import sanitize_markdown_value, sanitize_report_payload
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _timestamp() -> str:
|
|
18
|
+
return datetime.now().strftime("%Y-%m-%d_%H-%M-%S_%f")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _safe_filename_prefix(filename_prefix: str) -> str:
|
|
22
|
+
safe = re.sub(r"[^A-Za-z0-9_.-]+", "-", str(filename_prefix or "")).strip(".-")
|
|
23
|
+
return safe or "report"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _report_filename(filename_prefix: str, extension: str, *, timestamp: str | None = None) -> str:
|
|
27
|
+
report_timestamp = timestamp or _timestamp()
|
|
28
|
+
return (
|
|
29
|
+
f"{_safe_filename_prefix(filename_prefix)}_{report_timestamp}_{uuid4().hex[:8]}.{extension}"
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _esc(value: Any) -> str:
|
|
34
|
+
return sanitize_markdown_value(value)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _event_context_label(event: dict[str, Any]) -> str:
|
|
38
|
+
job = event.get("job")
|
|
39
|
+
if not isinstance(job, dict):
|
|
40
|
+
job = {}
|
|
41
|
+
company = event.get("company") or job.get("company") or ""
|
|
42
|
+
role = event.get("role") or event.get("title") or job.get("role") or job.get("title") or ""
|
|
43
|
+
return f"{_esc(company)} {_esc(role)}".strip()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def resolve_reports_dir(
|
|
47
|
+
paths: RuntimePaths | None = None,
|
|
48
|
+
reports_dir: str | Path | None = None,
|
|
49
|
+
) -> Path:
|
|
50
|
+
"""Resolve the report directory from explicit input or runtime paths."""
|
|
51
|
+
|
|
52
|
+
if reports_dir is not None:
|
|
53
|
+
return Path(reports_dir).expanduser()
|
|
54
|
+
if paths is not None:
|
|
55
|
+
return paths.reports_dir
|
|
56
|
+
raise ValueError("reports_dir or paths is required")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def write_json_report(
|
|
60
|
+
report: dict[str, Any],
|
|
61
|
+
*,
|
|
62
|
+
paths: RuntimePaths | None = None,
|
|
63
|
+
reports_dir: str | Path | None = None,
|
|
64
|
+
filename_prefix: str = "report",
|
|
65
|
+
) -> Path:
|
|
66
|
+
"""Write a JSON report under an explicit standalone report directory."""
|
|
67
|
+
|
|
68
|
+
target_dir = resolve_reports_dir(paths=paths, reports_dir=reports_dir)
|
|
69
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
70
|
+
target = target_dir / _report_filename(filename_prefix, "json")
|
|
71
|
+
sanitized = sanitize_report_payload(report)
|
|
72
|
+
target.write_text(
|
|
73
|
+
json.dumps(sanitized, indent=2, ensure_ascii=False, default=str),
|
|
74
|
+
encoding="utf-8",
|
|
75
|
+
)
|
|
76
|
+
return target
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def write_markdown_report(
|
|
80
|
+
report: dict[str, Any],
|
|
81
|
+
*,
|
|
82
|
+
paths: RuntimePaths | None = None,
|
|
83
|
+
reports_dir: str | Path | None = None,
|
|
84
|
+
filename_prefix: str = "report",
|
|
85
|
+
) -> Path:
|
|
86
|
+
"""Write a compact Markdown report under an explicit standalone directory."""
|
|
87
|
+
|
|
88
|
+
target_dir = resolve_reports_dir(paths=paths, reports_dir=reports_dir)
|
|
89
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
90
|
+
report_timestamp = _timestamp()
|
|
91
|
+
target = target_dir / _report_filename(filename_prefix, "md", timestamp=report_timestamp)
|
|
92
|
+
sanitized = sanitize_report_payload(report)
|
|
93
|
+
summary = sanitized.get("summary", {}) if isinstance(sanitized, dict) else {}
|
|
94
|
+
events = sanitized.get("events", []) if isinstance(sanitized, dict) else []
|
|
95
|
+
lines = [
|
|
96
|
+
f"# LinkedIn-apply-assistant Report - {report_timestamp}",
|
|
97
|
+
"",
|
|
98
|
+
]
|
|
99
|
+
if isinstance(summary, dict):
|
|
100
|
+
lines.append("## Summary")
|
|
101
|
+
lines.append("")
|
|
102
|
+
for key in sorted(summary):
|
|
103
|
+
lines.append(f"- **{_esc(key)}:** {_esc(summary[key])}")
|
|
104
|
+
lines.append("")
|
|
105
|
+
if isinstance(events, list):
|
|
106
|
+
lines.append("## Events")
|
|
107
|
+
lines.append("")
|
|
108
|
+
for event in events:
|
|
109
|
+
if isinstance(event, dict):
|
|
110
|
+
label = event.get("type", "event")
|
|
111
|
+
context_label = _event_context_label(event)
|
|
112
|
+
details = []
|
|
113
|
+
for key in (
|
|
114
|
+
"status",
|
|
115
|
+
"surface",
|
|
116
|
+
"ats",
|
|
117
|
+
"blocked_reason",
|
|
118
|
+
"filled_count",
|
|
119
|
+
"required_empty_count",
|
|
120
|
+
"unknown_count",
|
|
121
|
+
"domain",
|
|
122
|
+
):
|
|
123
|
+
value = event.get(key)
|
|
124
|
+
if value not in (None, "", [], {}):
|
|
125
|
+
details.append(f"{_esc(key)}={_esc(value)}")
|
|
126
|
+
suffix = f" - {'; '.join(details)}" if details else ""
|
|
127
|
+
lines.append(f"- **{_esc(label)}** {context_label}{suffix}".rstrip())
|
|
128
|
+
lines.append("")
|
|
129
|
+
target.write_text("\n".join(lines), encoding="utf-8")
|
|
130
|
+
return target
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def write_dry_run_report(jobs: list[dict[str, Any]], report_path: str | Path) -> Path:
|
|
134
|
+
"""Write a dry-run JSON report to an explicit file path."""
|
|
135
|
+
|
|
136
|
+
target = Path(report_path).expanduser()
|
|
137
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
138
|
+
passed = [job for job in jobs if job.get("pass")]
|
|
139
|
+
payload = {
|
|
140
|
+
"timestamp": _timestamp(),
|
|
141
|
+
"total": len(jobs),
|
|
142
|
+
"passed": len(passed),
|
|
143
|
+
"results": jobs,
|
|
144
|
+
}
|
|
145
|
+
safe_payload = sanitize_report_payload(payload)
|
|
146
|
+
target.write_text(json.dumps(safe_payload, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
147
|
+
return target
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def write_assistive_session_report(
|
|
151
|
+
report: dict[str, Any],
|
|
152
|
+
profile: dict[str, Any] | None = None,
|
|
153
|
+
*,
|
|
154
|
+
paths: RuntimePaths | None = None,
|
|
155
|
+
reports_dir: str | Path | None = None,
|
|
156
|
+
) -> tuple[Path, Path]:
|
|
157
|
+
"""Write JSON and Markdown reports for a local assistive session."""
|
|
158
|
+
|
|
159
|
+
prefix = "assistive-session"
|
|
160
|
+
payload = dict(report)
|
|
161
|
+
_ = profile
|
|
162
|
+
json_path = write_json_report(
|
|
163
|
+
payload,
|
|
164
|
+
paths=paths,
|
|
165
|
+
reports_dir=reports_dir,
|
|
166
|
+
filename_prefix=prefix,
|
|
167
|
+
)
|
|
168
|
+
md_path = write_markdown_report(
|
|
169
|
+
payload,
|
|
170
|
+
paths=paths,
|
|
171
|
+
reports_dir=reports_dir,
|
|
172
|
+
filename_prefix=prefix,
|
|
173
|
+
)
|
|
174
|
+
return json_path, md_path
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def write_search_report(
|
|
178
|
+
report: dict[str, Any],
|
|
179
|
+
*,
|
|
180
|
+
paths: RuntimePaths | None = None,
|
|
181
|
+
reports_dir: str | Path | None = None,
|
|
182
|
+
) -> tuple[Path, Path]:
|
|
183
|
+
"""Write JSON and Markdown reports for a search workflow."""
|
|
184
|
+
|
|
185
|
+
json_path = write_json_report(
|
|
186
|
+
report,
|
|
187
|
+
paths=paths,
|
|
188
|
+
reports_dir=reports_dir,
|
|
189
|
+
filename_prefix="search",
|
|
190
|
+
)
|
|
191
|
+
md_path = write_markdown_report(
|
|
192
|
+
report,
|
|
193
|
+
paths=paths,
|
|
194
|
+
reports_dir=reports_dir,
|
|
195
|
+
filename_prefix="search",
|
|
196
|
+
)
|
|
197
|
+
return json_path, md_path
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
class RuntimeReportSink:
|
|
201
|
+
"""ReportSink implementation backed by explicit runtime paths."""
|
|
202
|
+
|
|
203
|
+
def __init__(
|
|
204
|
+
self,
|
|
205
|
+
*,
|
|
206
|
+
paths: RuntimePaths | None = None,
|
|
207
|
+
reports_dir: str | Path | None = None,
|
|
208
|
+
) -> None:
|
|
209
|
+
self.paths = paths
|
|
210
|
+
self.reports_dir = reports_dir
|
|
211
|
+
|
|
212
|
+
def write(self, command: str, report: dict[str, Any]) -> list[ReportArtifact]:
|
|
213
|
+
prefix = "assistive-session" if command == "assist" else command
|
|
214
|
+
json_path = write_json_report(
|
|
215
|
+
report,
|
|
216
|
+
paths=self.paths,
|
|
217
|
+
reports_dir=self.reports_dir,
|
|
218
|
+
filename_prefix=prefix,
|
|
219
|
+
)
|
|
220
|
+
md_path = write_markdown_report(
|
|
221
|
+
report,
|
|
222
|
+
paths=self.paths,
|
|
223
|
+
reports_dir=self.reports_dir,
|
|
224
|
+
filename_prefix=prefix,
|
|
225
|
+
)
|
|
226
|
+
return [
|
|
227
|
+
ReportArtifact(kind="json", path=json_path),
|
|
228
|
+
ReportArtifact(kind="markdown", path=md_path),
|
|
229
|
+
]
|