googleformsubmitter 1.0.0 → 1.1.0
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/.env.example +13 -0
- package/README.md +59 -16
- package/package.json +9 -4
- package/patches/playwright-core@1.61.1.patch +31 -0
- package/src/google-auth-service.ts +64 -0
- package/src/google-drive-service.ts +36 -0
- package/{test.ts → src/google-form-submitter.test.ts} +61 -60
- package/{GoogleFormSubmitter.ts → src/google-form-submitter.ts} +75 -54
- package/src/index.ts +4 -0
- package/{types.ts → src/types.ts} +8 -0
- package/index.ts +0 -2
- /package/{create-form.ts → tools/create-form.ts} +0 -0
package/.env.example
CHANGED
|
@@ -2,3 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
# CDP Web Socket endpoint for connecting to the Lightpanda browser engine
|
|
4
4
|
LIGHTPANDA_CDP_URL=ws://127.0.0.1:9222/
|
|
5
|
+
|
|
6
|
+
# Path to the Google Forms session cookies JSON file (required for testing file upload fields)
|
|
7
|
+
GOOGLE_COOKIES_PATH=./.data/google-cookies.json
|
|
8
|
+
|
|
9
|
+
# Google OAuth2 Credentials (Option A: Direct Env configuration - recommended for Production/CI)
|
|
10
|
+
# GOOGLE_CLIENT_ID=your-client-id
|
|
11
|
+
# GOOGLE_CLIENT_SECRET=your-client-secret
|
|
12
|
+
# GOOGLE_REFRESH_TOKEN=your-refresh-token
|
|
13
|
+
|
|
14
|
+
# Google OAuth2 Credentials Paths (Option B: Custom File-based configuration - fallback option)
|
|
15
|
+
# GOOGLE_OAUTH_CREDENTIALS_PATH=./.data/credentials.json
|
|
16
|
+
# GOOGLE_OAUTH_TOKEN_PATH=./.data/forms-auth.json
|
|
17
|
+
|
package/README.md
CHANGED
|
@@ -2,29 +2,32 @@
|
|
|
2
2
|
|
|
3
3
|
Пакет для строго типизированной, валидируемой и кэшируемой отправки данных во внешние (в том числе сторонние) Google Формы с использованием Lightpanda (CDP-соединение) или стандартного Chromium.
|
|
4
4
|
|
|
5
|
+
---
|
|
6
|
+
|
|
5
7
|
## 🚀 Возможности
|
|
6
8
|
|
|
7
9
|
1. **Строгая валидация (JSON Schema)**: Входные данные проверяются через стандартную JSON схему с использованием библиотеки `Ajv` перед любой сетевой отправкой.
|
|
8
10
|
2. **Декларативное описание полей**: Позволяет сопоставлять программные свойства (например, `fullName`, `exitDate`) с русскими заголовками вопросов в Google Форме.
|
|
9
11
|
3. **Ленивый парсинг DOM (Lazy Loading)**: При первой отправке модуль автоматически открывает форму в безголовом браузере, сканирует структуру вопросов, находит соответствующие им бэкенд `entryId` и сохраняет карту соответствий в локальный кэш-файл.
|
|
10
12
|
4. **Сверхбыстрые повторные отправки**: После наполнения кэша последующие отправки выполняются мгновенно через прямой HTTP POST-запрос, эмулируя отправку формы без запуска графического браузера.
|
|
11
|
-
5. **Авторизация (
|
|
13
|
+
5. **Авторизация и загрузка файлов (Google Drive)**: Поддерживает инжекцию файлов куки для прохождения авторизации в формах, требующих аккаунт Google. Также инкапсулирует автоматическую загрузку файлов резюме (`Buffer`) на Google Диск через Google Drive API на лету с подстановкой ID файла в форму.
|
|
12
14
|
6. **Поддержка опции «Другое»**: Автоматически распознает кастомные текстовые ответы для радио-кнопок/чекбоксов с выбором «Другое» и правильно их кодирует.
|
|
15
|
+
7. **Инфраструктурная чистота**: Библиотека полностью независима от файловой структуры или конкретных `env`-переменных вашего приложения. Все настройки и пути передаются в конструктор.
|
|
13
16
|
|
|
14
17
|
---
|
|
15
18
|
|
|
16
19
|
## 🛠️ Установка и Запуск
|
|
17
20
|
|
|
18
|
-
Перейдите в каталог
|
|
21
|
+
Перейдите в каталог библиотеки и установите зависимости:
|
|
19
22
|
```bash
|
|
20
23
|
cd googleformsubmitter
|
|
21
24
|
bun install
|
|
22
25
|
```
|
|
23
26
|
|
|
24
27
|
### Доступные скрипты:
|
|
25
|
-
* **Запуск
|
|
28
|
+
* **Запуск тестов (стандартный Bun BDD runner)**:
|
|
26
29
|
```bash
|
|
27
|
-
bun
|
|
30
|
+
bun test
|
|
28
31
|
```
|
|
29
32
|
* **Проверка типов (tsgo)**:
|
|
30
33
|
```bash
|
|
@@ -41,17 +44,42 @@ bun install
|
|
|
41
44
|
|
|
42
45
|
---
|
|
43
46
|
|
|
47
|
+
## 🐼 Запуск Lightpanda
|
|
48
|
+
|
|
49
|
+
Для динамического разрешения DOM-структуры (когда кэш еще не заполнен) библиотека использует CDP-соединение с ультра-легким JS-движком **Lightpanda**.
|
|
50
|
+
|
|
51
|
+
Для его локального запуска выполните:
|
|
52
|
+
```bash
|
|
53
|
+
docker run --rm -p 9222:9222 lightpanda/browser:latest
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
*При отсутствии запущенного инстанса Lightpanda библиотека автоматически переключится на стандартный headless Chromium в качестве безопасного fallback.*
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
## 🩹 Патч Playwright для работы под Bun
|
|
61
|
+
|
|
62
|
+
В среде выполнения **Bun** есть известная проблема совместимости с CDP-событиями WebSocket, из-за которой стандартный Playwright зависает (hangs) при подключении к Lightpanda.
|
|
63
|
+
|
|
64
|
+
Для решения этой проблемы библиотека поставляется со встроенным патчем:
|
|
65
|
+
* Файл патча: `patches/playwright-core@1.61.1.patch`
|
|
66
|
+
* Регистрация в `package.json` через `"patchedDependencies"`.
|
|
67
|
+
|
|
68
|
+
При запуске `bun install` патч применяется автоматически.
|
|
69
|
+
|
|
70
|
+
---
|
|
71
|
+
|
|
44
72
|
## 📋 Описание Схемы данных
|
|
45
73
|
|
|
46
74
|
Отправка настраивается с помощью двух схем:
|
|
47
75
|
|
|
48
|
-
1. **JSON Schema** — описывает типы данных, ограничения, обязательность и
|
|
76
|
+
1. **JSON Schema** — описывает типы данных, ограничения, обязательность и формат.
|
|
49
77
|
2. **Mapping Schema** — описывает связь между ключом в JSON схеме и человекочитаемым заголовком вопроса в Google Форме.
|
|
50
78
|
|
|
51
79
|
### Пример декларативной конфигурации:
|
|
52
80
|
|
|
53
81
|
```typescript
|
|
54
|
-
import { GoogleFormSubmitter } from '
|
|
82
|
+
import { GoogleFormSubmitter } from 'googleformsubmitter';
|
|
55
83
|
|
|
56
84
|
// 1. Описание типов данных формы (JSON Schema)
|
|
57
85
|
const jsonSchema = {
|
|
@@ -63,11 +91,11 @@ const jsonSchema = {
|
|
|
63
91
|
cvFile: {
|
|
64
92
|
type: 'object',
|
|
65
93
|
properties: {
|
|
66
|
-
|
|
94
|
+
buffer: { type: 'object' },
|
|
67
95
|
filename: { type: 'string' },
|
|
68
96
|
mimeType: { type: 'string' }
|
|
69
97
|
},
|
|
70
|
-
required: ['
|
|
98
|
+
required: ['filename', 'mimeType']
|
|
71
99
|
}
|
|
72
100
|
},
|
|
73
101
|
required: ['fullName', 'birthDate', 'inStaff', 'cvFile']
|
|
@@ -87,7 +115,20 @@ const submitter = new GoogleFormSubmitter({
|
|
|
87
115
|
jsonSchema,
|
|
88
116
|
mappingSchema,
|
|
89
117
|
cdpUrl: 'ws://127.0.0.1:9222/', // CDP-сервер Lightpanda
|
|
90
|
-
|
|
118
|
+
cacheDir: './.data', // Папка для кэширования entryId
|
|
119
|
+
cookies: loadedCookies, // Сессионные куки (для авторизованных форм)
|
|
120
|
+
|
|
121
|
+
// Конфигурация авторизации Google Auth (Drive API)
|
|
122
|
+
auth: {
|
|
123
|
+
// Вариант А: Программная передача токенов напрямую (рекомендуется для Production/CI)
|
|
124
|
+
clientId: process.env.GOOGLE_CLIENT_ID,
|
|
125
|
+
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
|
|
126
|
+
refreshToken: process.env.GOOGLE_REFRESH_TOKEN,
|
|
127
|
+
|
|
128
|
+
// Вариант Б: Явные пути к конфигурационным файлам на диске
|
|
129
|
+
// credentialsPath: './.data/credentials.json',
|
|
130
|
+
// tokenPath: './.data/forms-auth.json'
|
|
131
|
+
}
|
|
91
132
|
});
|
|
92
133
|
|
|
93
134
|
// 4. Отправка данных
|
|
@@ -96,7 +137,7 @@ const result = await submitter.submit({
|
|
|
96
137
|
birthDate: '1990-01-01',
|
|
97
138
|
inStaff: 'С рынка, на предоффере', // Будет передано в "Другое"
|
|
98
139
|
cvFile: {
|
|
99
|
-
|
|
140
|
+
buffer: resumeBuffer,
|
|
100
141
|
filename: 'resume.docx',
|
|
101
142
|
mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
|
|
102
143
|
}
|
|
@@ -107,9 +148,11 @@ const result = await submitter.submit({
|
|
|
107
148
|
|
|
108
149
|
## 📂 Структура каталогов проекта
|
|
109
150
|
|
|
110
|
-
* `
|
|
111
|
-
* `
|
|
112
|
-
* `
|
|
113
|
-
* `types.ts` — TypeScript-интерфейсы и типы данных.
|
|
114
|
-
*
|
|
115
|
-
*
|
|
151
|
+
* `src/google-form-submitter.ts` — основной класс управления, парсинга DOM и сетевых запросов.
|
|
152
|
+
* `src/google-auth-service.ts` — сервис авторизации Google OAuth2 (поддерживает как токены из конструктора/env, так и из файлов).
|
|
153
|
+
* `src/google-drive-service.ts` — сервис загрузки файлов на Google Диск через Google Drive API.
|
|
154
|
+
* `src/types.ts` — TypeScript-интерфейсы и типы данных.
|
|
155
|
+
* `src/index.ts` — точка входа (публичные экспорты библиотеки).
|
|
156
|
+
* `src/google-form-submitter.test.ts` — BDD интеграционный тест (запускается через `bun test`).
|
|
157
|
+
* `tools/create-form.ts` — вспомогательный скрипт создания тестовой формы через Google Forms API.
|
|
158
|
+
* `patches/` — содержит патч для решения проблем с CDP Playwright под Bun.
|
package/package.json
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "googleformsubmitter",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"module": "index.ts",
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"module": "src/index.ts",
|
|
5
|
+
"main": "src/index.ts",
|
|
6
|
+
"types": "src/index.ts",
|
|
5
7
|
"type": "module",
|
|
6
8
|
"scripts": {
|
|
7
|
-
"test": "bun
|
|
9
|
+
"test": "bun test",
|
|
8
10
|
"lint": "oxlint",
|
|
9
11
|
"validate": "tsgo --noEmit",
|
|
10
|
-
"create-form": "bun run ./create-form.ts"
|
|
12
|
+
"create-form": "bun run ./tools/create-form.ts"
|
|
11
13
|
},
|
|
12
14
|
"devDependencies": {
|
|
13
15
|
"@types/bun": "latest",
|
|
@@ -22,5 +24,8 @@
|
|
|
22
24
|
"ajv": "^8.20.0",
|
|
23
25
|
"googleapis": "^173.0.0",
|
|
24
26
|
"playwright": "^1.61.1"
|
|
27
|
+
},
|
|
28
|
+
"patchedDependencies": {
|
|
29
|
+
"playwright-core@1.61.1": "patches/playwright-core@1.61.1.patch"
|
|
25
30
|
}
|
|
26
31
|
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
diff --git a/lib/coreBundle.js b/lib/coreBundle.js
|
|
2
|
+
index 7d8468fd89fd4d292eade471c36e757c23cbec44..6540f61c85efbb3e14d02cd06400750ed754731b 100644
|
|
3
|
+
--- a/lib/coreBundle.js
|
|
4
|
+
+++ b/lib/coreBundle.js
|
|
5
|
+
@@ -38873,7 +38873,7 @@ var init_transport = __esm({
|
|
6
|
+
"use strict";
|
|
7
|
+
init_happyEyeballs();
|
|
8
|
+
init_task();
|
|
9
|
+
- ws = require("./utilsBundle").ws;
|
|
10
|
+
+ ws = "Bun" in globalThis ? require(require.resolve("ws", { paths: ["/"] })) : require("./utilsBundle").ws;
|
|
11
|
+
perMessageDeflate2 = {
|
|
12
|
+
clientNoContextTakeover: true,
|
|
13
|
+
zlibDeflateOptions: {
|
|
14
|
+
@@ -49404,7 +49404,7 @@ var init_wvBrowser = __esm({
|
|
15
|
+
init_dialogBridge();
|
|
16
|
+
init_wvConnection();
|
|
17
|
+
init_wvPage();
|
|
18
|
+
- ws2 = require("./utilsBundle").ws;
|
|
19
|
+
+ ws2 = "Bun" in globalThis ? require(require.resolve("ws", { paths: ["/"] })) : require("./utilsBundle").ws;
|
|
20
|
+
DeferredWebSocketTransport = class {
|
|
21
|
+
constructor(url2, headers) {
|
|
22
|
+
this._closed = false;
|
|
23
|
+
@@ -71153,7 +71153,7 @@ var init_cdpRelay = __esm({
|
|
24
|
+
init_cdpRelayV2();
|
|
25
|
+
init_protocol();
|
|
26
|
+
debug15 = require("./utilsBundle").debug;
|
|
27
|
+
- ws3 = require("./utilsBundle").ws;
|
|
28
|
+
+ ws3 = "Bun" in globalThis ? require(require.resolve("ws", { paths: ["/"] })) : require("./utilsBundle").ws;
|
|
29
|
+
({ wsServer: wsServer3 } = require("./utilsBundle"));
|
|
30
|
+
debugLogger2 = debug15("pw:mcp:relay");
|
|
31
|
+
CDPRelayServer = class {
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import fs from 'fs/promises';
|
|
2
|
+
import { google } from 'googleapis';
|
|
3
|
+
import type { GoogleAuthOptions } from './types';
|
|
4
|
+
|
|
5
|
+
export class GoogleAuthService {
|
|
6
|
+
private authOptions?: GoogleAuthOptions;
|
|
7
|
+
|
|
8
|
+
constructor(authOptions?: GoogleAuthOptions) {
|
|
9
|
+
this.authOptions = authOptions;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Resolves Google Auth OAuth2 Client using explicitly provided credentials or paths.
|
|
14
|
+
*/
|
|
15
|
+
public async getGoogleAuthClient(): Promise<any> {
|
|
16
|
+
// 1. Try explicit programmatic credentials
|
|
17
|
+
const clientId = this.authOptions?.clientId;
|
|
18
|
+
const clientSecret = this.authOptions?.clientSecret;
|
|
19
|
+
const refreshToken = this.authOptions?.refreshToken;
|
|
20
|
+
|
|
21
|
+
if (clientId && clientSecret && refreshToken) {
|
|
22
|
+
const oauth2Client = new google.auth.OAuth2(clientId, clientSecret);
|
|
23
|
+
oauth2Client.setCredentials({ refresh_token: refreshToken });
|
|
24
|
+
await oauth2Client.getAccessToken();
|
|
25
|
+
return oauth2Client;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// 2. Try explicit file paths
|
|
29
|
+
const credentialsPath = this.authOptions?.credentialsPath;
|
|
30
|
+
const tokenPath = this.authOptions?.tokenPath;
|
|
31
|
+
|
|
32
|
+
if (!credentialsPath || !tokenPath) {
|
|
33
|
+
throw new Error(
|
|
34
|
+
'[GoogleFormSubmitter] Google OAuth credentials are not configured. ' +
|
|
35
|
+
'Please pass { clientId, clientSecret, refreshToken } programmatically, ' +
|
|
36
|
+
'or specify both { credentialsPath, tokenPath } in auth options.'
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Load credentials from credentials.json
|
|
41
|
+
const credentialsContent = await fs.readFile(credentialsPath, 'utf-8');
|
|
42
|
+
const credentials = JSON.parse(credentialsContent);
|
|
43
|
+
const fileClientId = credentials.client_id || credentials.web?.client_id || credentials.installed?.client_id;
|
|
44
|
+
const fileClientSecret = credentials.client_secret || credentials.web?.client_secret || credentials.installed?.client_secret;
|
|
45
|
+
|
|
46
|
+
// Load token from forms-auth.json
|
|
47
|
+
const tokenContent = await fs.readFile(tokenPath, 'utf-8');
|
|
48
|
+
const token = JSON.parse(tokenContent);
|
|
49
|
+
const fileRefreshToken = token.refresh_token;
|
|
50
|
+
|
|
51
|
+
if (!fileClientId || !fileClientSecret || !fileRefreshToken) {
|
|
52
|
+
throw new Error(
|
|
53
|
+
'[GoogleFormSubmitter] Invalid OAuth configuration in provided credential files: ' +
|
|
54
|
+
'client_id, client_secret, or refresh_token is missing.'
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const oauth2Client = new google.auth.OAuth2(fileClientId, fileClientSecret);
|
|
59
|
+
oauth2Client.setCredentials({ refresh_token: fileRefreshToken });
|
|
60
|
+
|
|
61
|
+
await oauth2Client.getAccessToken();
|
|
62
|
+
return oauth2Client;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { google } from 'googleapis';
|
|
2
|
+
import { Readable } from 'stream';
|
|
3
|
+
import type { GoogleAuthService } from './google-auth-service';
|
|
4
|
+
|
|
5
|
+
export class GoogleDriveService {
|
|
6
|
+
private authService: GoogleAuthService;
|
|
7
|
+
|
|
8
|
+
constructor(authService: GoogleAuthService) {
|
|
9
|
+
this.authService = authService;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Uploads a file buffer directly to Google Drive.
|
|
14
|
+
*/
|
|
15
|
+
public async uploadFile(filename: string, mimeType: string, buffer: Buffer): Promise<string> {
|
|
16
|
+
const auth = await this.authService.getGoogleAuthClient();
|
|
17
|
+
const drive = google.drive({ version: 'v3', auth });
|
|
18
|
+
|
|
19
|
+
const response = await drive.files.create({
|
|
20
|
+
requestBody: {
|
|
21
|
+
name: filename
|
|
22
|
+
},
|
|
23
|
+
media: {
|
|
24
|
+
mimeType,
|
|
25
|
+
body: Readable.from(buffer)
|
|
26
|
+
},
|
|
27
|
+
fields: 'id'
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const id = response.data.id;
|
|
31
|
+
if (!id) {
|
|
32
|
+
throw new Error('[GoogleFormSubmitter] Failed to upload file to Google Drive: response did not contain file ID');
|
|
33
|
+
}
|
|
34
|
+
return id;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { describe, expect, test, beforeAll } from 'bun:test';
|
|
2
|
+
import { GoogleFormSubmitter } from './google-form-submitter';
|
|
2
3
|
import type { FormSchemaMapping } from './types';
|
|
3
4
|
import fs from 'fs/promises';
|
|
4
5
|
import path from 'path';
|
|
@@ -21,10 +22,11 @@ const jsonSchema = {
|
|
|
21
22
|
type: 'object',
|
|
22
23
|
properties: {
|
|
23
24
|
fileId: { type: 'string' },
|
|
25
|
+
buffer: { type: 'object' },
|
|
24
26
|
filename: { type: 'string' },
|
|
25
27
|
mimeType: { type: 'string' }
|
|
26
28
|
},
|
|
27
|
-
required: ['
|
|
29
|
+
required: ['filename', 'mimeType']
|
|
28
30
|
},
|
|
29
31
|
company: { type: 'string' },
|
|
30
32
|
checklist: { type: 'string' },
|
|
@@ -65,75 +67,74 @@ const mappingSchema: FormSchemaMapping = {
|
|
|
65
67
|
comment: { label: 'Комментарий', type: 'paragraph' }
|
|
66
68
|
};
|
|
67
69
|
|
|
68
|
-
|
|
69
|
-
console.log('🚀 Starting Google Form Submitter Integration Test...');
|
|
70
|
-
|
|
71
|
-
// Load cookies from local scratch path if it exists
|
|
70
|
+
describe('GoogleFormSubmitter E2E Integration', () => {
|
|
72
71
|
let cookies: any[] = [];
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
cookies
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
72
|
+
let submitter: GoogleFormSubmitter;
|
|
73
|
+
|
|
74
|
+
beforeAll(async () => {
|
|
75
|
+
// Load cookies from local environment path if it exists
|
|
76
|
+
const localCookiesPath = process.env.GOOGLE_COOKIES_PATH || path.join(__dirname, '..', '.data', 'google-cookies.json');
|
|
77
|
+
try {
|
|
78
|
+
const cookiesStr = await fs.readFile(localCookiesPath, 'utf8');
|
|
79
|
+
cookies = JSON.parse(cookiesStr);
|
|
80
|
+
console.log(`[Test] Loaded ${cookies.length} session cookies from ${localCookiesPath}`);
|
|
81
|
+
} catch (err: any) {
|
|
82
|
+
console.warn(`[Test] Warning: Could not load local session cookies: ${err.message}`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const formUrl = 'https://docs.google.com/forms/d/e/1FAIpQLSc1aiEAhxWCMSNjZ1asGDtXV_mT8vTiJRquiqfdjsoMkKT9tw/viewform';
|
|
86
|
+
const credentialsPath = path.join(__dirname, '..', '..', 'imatching', '.data', 'credentials.json');
|
|
87
|
+
const tokenPath = path.join(__dirname, '..', '..', 'imatching', '.data', 'forms-auth.json');
|
|
81
88
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
89
|
+
submitter = new GoogleFormSubmitter({
|
|
90
|
+
formUrl,
|
|
91
|
+
jsonSchema,
|
|
92
|
+
mappingSchema,
|
|
93
|
+
cdpUrl: 'ws://127.0.0.1:9222/', // CDP endpoint to Lightpanda
|
|
94
|
+
cacheDir: './.data',
|
|
95
|
+
auth: {
|
|
96
|
+
credentialsPath,
|
|
97
|
+
tokenPath
|
|
98
|
+
},
|
|
99
|
+
cookies
|
|
100
|
+
});
|
|
91
101
|
});
|
|
92
102
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
103
|
+
test('should dynamically resolve fields, upload file, and submit successfully', async () => {
|
|
104
|
+
const candidateData = {
|
|
105
|
+
requestId: '910',
|
|
106
|
+
fullName: 'Багманов Алмаз (Схема)',
|
|
107
|
+
birthDate: '1996-07-03',
|
|
108
|
+
grade: 'Middle',
|
|
109
|
+
location: 'Россия, Набережные Челны',
|
|
110
|
+
rate: '2000',
|
|
111
|
+
nds: 'Нет',
|
|
112
|
+
exitDate: 'через 21 дней',
|
|
113
|
+
vacation: 'нет',
|
|
114
|
+
inStaff: 'Да',
|
|
115
|
+
cvFile: {
|
|
116
|
+
buffer: Buffer.from('mock cv file contents for integration test'),
|
|
117
|
+
filename: 'cv_Багманов_Алмаз.docx',
|
|
118
|
+
mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
|
|
119
|
+
},
|
|
120
|
+
company: 'Iconicompany',
|
|
121
|
+
checklist: '📊 Чек-лист: 77% (Kotlin, Android, Compose, Coroutines)',
|
|
122
|
+
comment: 'Отправлено автоматически через типизированный маппер.'
|
|
123
|
+
};
|
|
114
124
|
|
|
115
|
-
try {
|
|
116
|
-
// Perform first submission (will trigger lazy DOM-resolution and save cache)
|
|
117
125
|
console.log('\n--- FIRST RUN: Performing dynamic DOM-resolution and submission ---');
|
|
118
126
|
const result1 = await submitter.submit(candidateData);
|
|
119
|
-
console.log('Result 1 Success:', result1.success);
|
|
120
|
-
console.log('Result 1 Status Code:', result1.statusCode);
|
|
121
127
|
console.log('Result 1 Fields Submitted:', JSON.stringify(result1.fieldsSubmitted, null, 2));
|
|
122
128
|
|
|
123
|
-
|
|
129
|
+
expect(result1.success).toBe(true);
|
|
130
|
+
expect(result1.statusCode).toBe(200);
|
|
131
|
+
|
|
124
132
|
console.log('\n--- SECOND RUN: Submitting instantly using cache ---');
|
|
125
|
-
// Change name slightly to verify a new answer is posted
|
|
126
133
|
candidateData.fullName = 'Багманов Алмаз (Схема-Кэш)';
|
|
127
134
|
const result2 = await submitter.submit(candidateData);
|
|
128
|
-
console.log('Result 2 Success:', result2.success);
|
|
129
|
-
console.log('Result 2 Status Code:', result2.statusCode);
|
|
130
135
|
console.log('Result 2 Fields Submitted:', JSON.stringify(result2.fieldsSubmitted, null, 2));
|
|
131
136
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
run();
|
|
137
|
+
expect(result2.success).toBe(true);
|
|
138
|
+
expect(result2.statusCode).toBe(200);
|
|
139
|
+
}, 45000); // 45 seconds timeout
|
|
140
|
+
});
|
|
@@ -2,7 +2,9 @@ import { chromium } from 'playwright';
|
|
|
2
2
|
import Ajv from 'ajv';
|
|
3
3
|
import fs from 'fs/promises';
|
|
4
4
|
import path from 'path';
|
|
5
|
-
import type { FormSchemaMapping, SubmissionResult } from './types';
|
|
5
|
+
import type { FormSchemaMapping, SubmissionResult, GoogleAuthOptions } from './types';
|
|
6
|
+
import { GoogleAuthService } from './google-auth-service';
|
|
7
|
+
import { GoogleDriveService } from './google-drive-service';
|
|
6
8
|
|
|
7
9
|
const ajv = new Ajv({ allErrors: true });
|
|
8
10
|
|
|
@@ -15,6 +17,8 @@ export class GoogleFormSubmitter {
|
|
|
15
17
|
private entryIdCache: Record<string, string> | null = null;
|
|
16
18
|
private validateFn: ReturnType<typeof ajv.compile>;
|
|
17
19
|
private cookies?: Array<any>;
|
|
20
|
+
private authService: GoogleAuthService;
|
|
21
|
+
private driveService: GoogleDriveService;
|
|
18
22
|
|
|
19
23
|
constructor(options: {
|
|
20
24
|
formUrl: string;
|
|
@@ -23,13 +27,17 @@ export class GoogleFormSubmitter {
|
|
|
23
27
|
cdpUrl?: string;
|
|
24
28
|
cacheDir?: string;
|
|
25
29
|
cookies?: Array<any>;
|
|
30
|
+
auth?: GoogleAuthOptions;
|
|
26
31
|
}) {
|
|
27
32
|
this.formUrl = options.formUrl;
|
|
28
33
|
this.jsonSchema = options.jsonSchema;
|
|
29
34
|
this.mappingSchema = options.mappingSchema;
|
|
30
35
|
this.cdpUrl = options.cdpUrl || 'ws://127.0.0.1:9222/';
|
|
31
36
|
this.cookies = options.cookies;
|
|
32
|
-
|
|
37
|
+
|
|
38
|
+
this.authService = new GoogleAuthService(options.auth);
|
|
39
|
+
this.driveService = new GoogleDriveService(this.authService);
|
|
40
|
+
|
|
33
41
|
// Compile JSON Schema validation function
|
|
34
42
|
this.validateFn = ajv.compile(this.jsonSchema);
|
|
35
43
|
|
|
@@ -37,7 +45,7 @@ export class GoogleFormSubmitter {
|
|
|
37
45
|
const formIdMatch = this.formUrl.match(/\/d\/e\/([a-zA-Z0-9_-]+)/) || this.formUrl.match(/\/d\/([a-zA-Z0-9_-]+)/);
|
|
38
46
|
const formHash = formIdMatch ? formIdMatch[1] : Buffer.from(this.formUrl).toString('base64').substring(0, 16);
|
|
39
47
|
|
|
40
|
-
const cacheDir = options.cacheDir ||
|
|
48
|
+
const cacheDir = options.cacheDir || process.cwd();
|
|
41
49
|
this.cacheFilePath = path.join(cacheDir, `form-cache-${formHash}.json`);
|
|
42
50
|
}
|
|
43
51
|
|
|
@@ -83,13 +91,13 @@ export class GoogleFormSubmitter {
|
|
|
83
91
|
|
|
84
92
|
try {
|
|
85
93
|
await page.goto(this.formUrl);
|
|
86
|
-
// Wait for
|
|
87
|
-
await
|
|
94
|
+
// Wait for page load and check if form is closed
|
|
95
|
+
await this.checkClosedAndGetPage(page);
|
|
88
96
|
|
|
89
97
|
// Scan DOM for question containers
|
|
90
98
|
const domMappings = await page.evaluate(() => {
|
|
91
99
|
const result: Record<string, string> = {};
|
|
92
|
-
const containers = document.querySelectorAll('.Qr7Oae');
|
|
100
|
+
const containers = (globalThis as any).document.querySelectorAll('.Qr7Oae');
|
|
93
101
|
|
|
94
102
|
containers.forEach((container: any) => {
|
|
95
103
|
const headerEl = container.querySelector('[role="heading"], .M7eMe, .HoRgec');
|
|
@@ -111,24 +119,19 @@ export class GoogleFormSubmitter {
|
|
|
111
119
|
}
|
|
112
120
|
}
|
|
113
121
|
|
|
114
|
-
// Strategy B: Parse
|
|
122
|
+
// Strategy B: Parse data-params attribute in container or child elements
|
|
115
123
|
if (!entryId) {
|
|
116
|
-
|
|
117
|
-
if (
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
dataParams = el.getAttribute('data-params') || '';
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
if (dataParams) {
|
|
124
|
-
const match = dataParams.match(/\[\[(\d+)/);
|
|
124
|
+
const paramsEl = container.hasAttribute('data-params') ? container : container.querySelector('[data-params]');
|
|
125
|
+
if (paramsEl) {
|
|
126
|
+
const dataParams = paramsEl.getAttribute('data-params');
|
|
127
|
+
const match = dataParams?.match(/\[\[(\d+)/);
|
|
125
128
|
if (match && match[1]) {
|
|
126
129
|
entryId = match[1];
|
|
127
130
|
}
|
|
128
131
|
}
|
|
129
132
|
}
|
|
130
133
|
|
|
131
|
-
if (
|
|
134
|
+
if (entryId) {
|
|
132
135
|
result[labelText] = entryId;
|
|
133
136
|
}
|
|
134
137
|
});
|
|
@@ -136,26 +139,13 @@ export class GoogleFormSubmitter {
|
|
|
136
139
|
return result;
|
|
137
140
|
});
|
|
138
141
|
|
|
139
|
-
|
|
142
|
+
console.log(`[GoogleFormSubmitter] Discovered ${Object.keys(domMappings).length} fields in Form DOM.`);
|
|
143
|
+
|
|
144
|
+
// 3. Map properties from mappingSchema to Google Forms entryIds
|
|
140
145
|
const resolvedCache: Record<string, string> = {};
|
|
141
146
|
for (const [propName, fieldMapping] of Object.entries(this.mappingSchema)) {
|
|
142
|
-
//
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
// Try exact match first
|
|
146
|
-
matchedEntryId = domMappings[fieldMapping.label];
|
|
147
|
-
|
|
148
|
-
// Try substring match as fallback
|
|
149
|
-
if (!matchedEntryId) {
|
|
150
|
-
const lowerLabel = fieldMapping.label.toLowerCase();
|
|
151
|
-
const scrapedTitle = Object.keys(domMappings).find(
|
|
152
|
-
(t) => t.toLowerCase().includes(lowerLabel) || lowerLabel.includes(t.toLowerCase())
|
|
153
|
-
);
|
|
154
|
-
if (scrapedTitle) {
|
|
155
|
-
matchedEntryId = domMappings[scrapedTitle];
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
|
|
147
|
+
// Find matching entryId by label
|
|
148
|
+
const matchedEntryId = domMappings[fieldMapping.label];
|
|
159
149
|
if (matchedEntryId) {
|
|
160
150
|
resolvedCache[propName] = matchedEntryId;
|
|
161
151
|
} else {
|
|
@@ -176,6 +166,26 @@ export class GoogleFormSubmitter {
|
|
|
176
166
|
}
|
|
177
167
|
}
|
|
178
168
|
|
|
169
|
+
/**
|
|
170
|
+
* Safe check to determine if the form is closed, throwing a clear error instead of timing out.
|
|
171
|
+
*/
|
|
172
|
+
private async checkClosedAndGetPage(page: any): Promise<void> {
|
|
173
|
+
await Promise.any([
|
|
174
|
+
page.locator('.Qr7Oae').first().waitFor({ state: 'attached', timeout: 15000 }),
|
|
175
|
+
page.waitForURL(/\/closedform/, { timeout: 15000 }).catch(() => {})
|
|
176
|
+
]).catch(() => {});
|
|
177
|
+
|
|
178
|
+
const isClosed = page.url().includes('/closedform') || (await page.locator('form').count() === 0);
|
|
179
|
+
|
|
180
|
+
if (isClosed) {
|
|
181
|
+
throw new Error('Google Form is closed (no longer accepting responses)');
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (await page.locator('.Qr7Oae').count() === 0) {
|
|
185
|
+
throw new Error('Could not find question containers on the page.');
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
179
189
|
/**
|
|
180
190
|
* Validates input data and submits the form via direct HTTP POST.
|
|
181
191
|
*/
|
|
@@ -186,7 +196,7 @@ export class GoogleFormSubmitter {
|
|
|
186
196
|
const errorMsg = this.validateFn.errors
|
|
187
197
|
?.map((err) => `${err.instancePath} ${err.message}`)
|
|
188
198
|
.join(', ');
|
|
189
|
-
throw new Error(
|
|
199
|
+
throw new Error("Validation failed: " + errorMsg);
|
|
190
200
|
}
|
|
191
201
|
|
|
192
202
|
const typedData = data as Record<string, any>;
|
|
@@ -215,11 +225,18 @@ export class GoogleFormSubmitter {
|
|
|
215
225
|
const currentList = fieldsMap[entryId];
|
|
216
226
|
|
|
217
227
|
if (fieldMapping.type === 'file') {
|
|
218
|
-
if (value && typeof value === 'object' && 'fileId' in value) {
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
228
|
+
if (value && typeof value === 'object' && ('fileId' in value || 'buffer' in value)) {
|
|
229
|
+
let fileId = value.fileId;
|
|
230
|
+
if (!fileId && value.buffer) {
|
|
231
|
+
console.log(`[GoogleFormSubmitter] File buffer detected. Uploading "${value.filename}" to Google Drive...`);
|
|
232
|
+
fileId = await this.driveService.uploadFile(value.filename, value.mimeType, value.buffer);
|
|
233
|
+
}
|
|
234
|
+
if (fileId) {
|
|
235
|
+
const filePayload = [[[fileId, value.filename, value.mimeType]]];
|
|
236
|
+
const serialized = JSON.stringify(filePayload);
|
|
237
|
+
currentList?.push(serialized);
|
|
238
|
+
fieldsSubmitted[fieldMapping.label] = serialized;
|
|
239
|
+
}
|
|
223
240
|
}
|
|
224
241
|
} else if (fieldMapping.type === 'choice' && fieldMapping.allowOther && value) {
|
|
225
242
|
const isPredefined = fieldMapping.options?.choices?.includes(value);
|
|
@@ -264,7 +281,7 @@ export class GoogleFormSubmitter {
|
|
|
264
281
|
|
|
265
282
|
try {
|
|
266
283
|
await page.goto(this.formUrl);
|
|
267
|
-
await
|
|
284
|
+
await this.checkClosedAndGetPage(page);
|
|
268
285
|
|
|
269
286
|
console.log(`[GoogleFormSubmitter] Sending HTTP POST request in page context...`);
|
|
270
287
|
const responseInfo = await page.evaluate(async ({ fieldsMap }) => {
|
|
@@ -282,34 +299,38 @@ export class GoogleFormSubmitter {
|
|
|
282
299
|
}
|
|
283
300
|
|
|
284
301
|
for (const [entryId, values] of Object.entries(fieldsMap)) {
|
|
285
|
-
|
|
302
|
+
const list = values as string[];
|
|
303
|
+
for (const val of list) {
|
|
286
304
|
searchParams.append(`entry.${entryId}`, val);
|
|
287
305
|
}
|
|
288
306
|
}
|
|
289
307
|
|
|
290
|
-
const actionUrl = form.action ||
|
|
291
|
-
|
|
292
|
-
|
|
308
|
+
const actionUrl = form.getAttribute('action') || '';
|
|
309
|
+
const resolvedActionUrl = new URL(actionUrl, g.location.href).href;
|
|
310
|
+
|
|
311
|
+
console.log(`Sending POST to ${resolvedActionUrl}...`);
|
|
312
|
+
const response = await g.fetch(resolvedActionUrl, {
|
|
293
313
|
method: 'POST',
|
|
294
|
-
body: searchParams,
|
|
295
314
|
headers: {
|
|
296
315
|
'Content-Type': 'application/x-www-form-urlencoded'
|
|
297
|
-
}
|
|
316
|
+
},
|
|
317
|
+
body: searchParams.toString()
|
|
298
318
|
});
|
|
299
319
|
|
|
300
320
|
return {
|
|
301
|
-
status:
|
|
302
|
-
|
|
321
|
+
status: response.status,
|
|
322
|
+
url: response.url,
|
|
323
|
+
bodyText: await response.text()
|
|
303
324
|
};
|
|
304
325
|
}, { fieldsMap });
|
|
305
326
|
|
|
306
|
-
const
|
|
327
|
+
const success = responseInfo.status === 200;
|
|
307
328
|
|
|
308
329
|
return {
|
|
309
|
-
success
|
|
330
|
+
success,
|
|
310
331
|
statusCode: responseInfo.status,
|
|
311
|
-
message:
|
|
312
|
-
fieldsSubmitted
|
|
332
|
+
message: success ? 'Form submitted successfully' : `Server responded with status ${responseInfo.status}`,
|
|
333
|
+
fieldsSubmitted
|
|
313
334
|
};
|
|
314
335
|
} finally {
|
|
315
336
|
await context.close();
|
package/src/index.ts
ADDED
|
@@ -16,3 +16,11 @@ export interface SubmissionResult {
|
|
|
16
16
|
message?: string;
|
|
17
17
|
fieldsSubmitted: Record<string, string>;
|
|
18
18
|
}
|
|
19
|
+
|
|
20
|
+
export interface GoogleAuthOptions {
|
|
21
|
+
clientId?: string;
|
|
22
|
+
clientSecret?: string;
|
|
23
|
+
refreshToken?: string;
|
|
24
|
+
credentialsPath?: string;
|
|
25
|
+
tokenPath?: string;
|
|
26
|
+
}
|
package/index.ts
DELETED
|
File without changes
|