googleformsubmitter 1.1.0 → 1.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "googleformsubmitter",
3
- "version": "1.1.0",
3
+ "version": "1.1.1",
4
4
  "module": "src/index.ts",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -9,7 +9,8 @@
9
9
  "test": "bun test",
10
10
  "lint": "oxlint",
11
11
  "validate": "tsgo --noEmit",
12
- "create-form": "bun run ./tools/create-form.ts"
12
+ "create-form": "bun run ./tools/create-form.ts",
13
+ "introspect-form": "bun run ./tools/introspect-form.ts"
13
14
  },
14
15
  "devDependencies": {
15
16
  "@types/bun": "latest",
@@ -0,0 +1,264 @@
1
+ import { chromium } from 'playwright';
2
+ import fs from 'fs/promises';
3
+ import path from 'path';
4
+
5
+ function toCamelCase(str: string): string {
6
+ // Map common Russian terms to standard project English keys
7
+ const translations: Record<string, string> = {
8
+ 'фио': 'fullName',
9
+ 'дата рождения': 'birthDate',
10
+ 'грейд': 'grade',
11
+ 'локация': 'location',
12
+ 'рейт': 'rate',
13
+ 'ндс': 'nds',
14
+ 'когда кандидат': 'exitDate',
15
+ 'отпуск': 'vacation',
16
+ 'штате': 'inStaff',
17
+ 'cv': 'cvFile',
18
+ 'резюме': 'cvFile',
19
+ 'компания': 'company',
20
+ 'контакты': 'contacts',
21
+ 'требованиям': 'checklist',
22
+ 'комментарий': 'comment',
23
+ 'номер запроса': 'requestId'
24
+ };
25
+
26
+ const lower = str.toLowerCase();
27
+ for (const [key, val] of Object.entries(translations)) {
28
+ if (lower.includes(key)) {
29
+ return val;
30
+ }
31
+ }
32
+
33
+ // Fallback to transliteration + camelCase
34
+ const clean = str
35
+ .replace(/[^a-zA-Zа-яА-Я0-9\s]/g, '')
36
+ .trim()
37
+ .replace(/\s+/g, ' ');
38
+
39
+ const ru: Record<string, string> = {
40
+ 'а':'a', 'б':'b', 'в':'v', 'г':'g', 'д':'d', 'е':'e', 'ё':'yo', 'ж':'zh', 'з':'z', 'и':'i', 'й':'y', 'к':'k', 'л':'l', 'м':'m', 'н':'n', 'о':'o', 'п':'p', 'р':'r', 'с':'s', 'т':'t', 'у':'u', 'ф':'f', 'х':'h', 'ц':'ts', 'ч':'ch', 'ш':'sh', 'щ':'sch', 'ъ':'', 'ы':'y', 'ь':'', 'э':'e', 'ю':'yu', 'я':'ya',
41
+ 'А':'A', 'Б':'B', 'В':'V', 'Г':'G', 'Д':'D', 'Е':'E', 'Ё':'Yo', 'Ж':'Zh', 'З':'Z', 'И':'I', 'Й':'Y', 'К':'K', 'Л':'L', 'М':'M', 'Н':'N', 'О':'O', 'П':'P', 'Р':'R', 'С':'S', 'Т':'T', 'У':'U', 'Ф':'F', 'Х':'H', 'Ц':'Ts', 'Ч':'Ch', 'Ш':'Sh', 'Щ':'Sch', 'Ъ':'', 'Ы':'Y', 'Ь':'', 'Э':'E', 'Ю':'Yu', 'Я':'Ya'
42
+ };
43
+
44
+ let transliterated = '';
45
+ for (const char of clean) {
46
+ const replacement = ru[char] !== undefined ? ru[char] : char;
47
+ transliterated += replacement;
48
+ }
49
+
50
+ const words = transliterated.split(' ');
51
+ return words
52
+ .map((word, idx) => {
53
+ if (idx === 0) return word.toLowerCase();
54
+ return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
55
+ })
56
+ .join('');
57
+ }
58
+
59
+ async function main() {
60
+ const url = process.argv[2];
61
+ if (!url) {
62
+ console.error('Usage: bun run ./tools/introspect-form.ts <google-form-url>');
63
+ process.exit(1);
64
+ }
65
+
66
+ console.log(`🔍 Connecting to Google Form at: ${url}...`);
67
+
68
+ let browser;
69
+ try {
70
+ // Attempt connecting to local Lightpanda first
71
+ browser = await chromium.connectOverCDP(process.env.LIGHTPANDA_CDP_URL || 'ws://127.0.0.1:9222/', { timeout: 5000 });
72
+ } catch {
73
+ console.log('Lightpanda not found, falling back to local headless Chromium...');
74
+ browser = await chromium.launch({ headless: true });
75
+ }
76
+
77
+ const context = await browser.newContext();
78
+
79
+ // Load session cookies if GOOGLE_COOKIES_PATH is configured
80
+ const cookiesPath = process.env.GOOGLE_COOKIES_PATH;
81
+ if (cookiesPath) {
82
+ try {
83
+ const cookiesStr = await fs.readFile(cookiesPath, 'utf8');
84
+ const cookies = JSON.parse(cookiesStr);
85
+ await context.addCookies(cookies);
86
+ console.log(`[Introspect] Injected ${cookies.length} session cookies from ${cookiesPath}`);
87
+ } catch (e: any) {
88
+ console.warn(`[Introspect] Warning: Could not inject cookies: ${e.message}`);
89
+ }
90
+ }
91
+
92
+ const page = await context.newPage();
93
+ page.on('console', msg => console.log('PAGE LOG:', msg.text()));
94
+
95
+ try {
96
+ await page.goto(url);
97
+
98
+ // Wait for the form contents to load
99
+ await Promise.any([
100
+ page.locator('.Qr7Oae').first().waitFor({ state: 'attached', timeout: 15000 }),
101
+ page.waitForURL(/\/closedform/, { timeout: 15000 }).catch(() => {})
102
+ ]).catch(() => {});
103
+
104
+ if (page.url().includes('/closedform') || (await page.locator('form').count() === 0)) {
105
+ throw new Error('This Google Form is closed or redirecting to login. Make sure your session cookies are valid.');
106
+ }
107
+
108
+ // Inspect DOM
109
+ const questions = await page.evaluate(() => {
110
+ const g = globalThis as any;
111
+ const result: any[] = [];
112
+ const containers = g.document.querySelectorAll('.Qr7Oae');
113
+
114
+ containers.forEach((container: any) => {
115
+ const headerEl = container.querySelector('[role="heading"], .M7eMe, .HoRgec');
116
+ if (!headerEl) return;
117
+
118
+ let labelText = (headerEl.textContent || '').trim();
119
+ const isRequired = labelText.endsWith('*');
120
+ labelText = labelText.replace(/\s*\*$/, '').trim();
121
+
122
+ let type = 'text';
123
+ let choices: string[] = [];
124
+ let allowOther = false;
125
+
126
+ const paramsEl = container.hasAttribute('data-params') ? container : container.querySelector('[data-params]');
127
+ if (paramsEl) {
128
+ const dataParams = paramsEl.getAttribute('data-params');
129
+ try {
130
+ // Replace Google Forms prefix with '[' to restore the outer array opening bracket
131
+ const jsonText = dataParams.replace(/^%\.@\./, '[');
132
+ const parsed = JSON.parse(jsonText);
133
+ const questionInfo = parsed[0];
134
+ const typeId = questionInfo[3];
135
+
136
+ if (typeId === 11 || typeId === 13) {
137
+ type = 'file';
138
+ } else if (typeId === 9) {
139
+ type = 'date';
140
+ } else if (typeId === 1) {
141
+ type = 'paragraph';
142
+ } else if (typeId === 2 || typeId === 3 || typeId === 4) {
143
+ type = 'choice';
144
+ const choicesArray = questionInfo[4]?.[0]?.[1];
145
+ if (choicesArray && Array.isArray(choicesArray)) {
146
+ choices = choicesArray.map((c: any) => c[0]).filter((val) => val !== null && val !== undefined && val !== '');
147
+ }
148
+ allowOther = !!questionInfo[4]?.[0]?.[2];
149
+ }
150
+ } catch (e: any) {
151
+ console.error('PAGE LOG [Parser Error]:', e.message, 'Data:', dataParams);
152
+ // Fallback to legacy DOM checks if parse fails
153
+ if (container.querySelector('[type="file"]') || container.querySelector('[data-params*="file"]')) {
154
+ type = 'file';
155
+ } else if (container.querySelector('input[type="date"]') || container.querySelector('[data-params*="[[3,"]')) {
156
+ type = 'date';
157
+ } else if (container.querySelector('[role="radio"], [role="checkbox"], select, .SGZTVe')) {
158
+ type = 'choice';
159
+ const optionEls = container.querySelectorAll('[role="radio"] + *, [role="checkbox"] + *, option, .fwW70c, .text');
160
+ optionEls.forEach((opt: any) => {
161
+ const text = (opt.textContent || '').trim();
162
+ if (text && !text.toLowerCase().includes('другое') && !text.toLowerCase().includes('other')) {
163
+ choices.push(text);
164
+ }
165
+ });
166
+ } else if (container.querySelector('textarea')) {
167
+ type = 'paragraph';
168
+ }
169
+ }
170
+ } else {
171
+ console.warn('PAGE LOG: paramsEl not found for label:', labelText);
172
+ }
173
+
174
+ result.push({
175
+ label: labelText,
176
+ type,
177
+ required: isRequired,
178
+ choices: choices.length > 0 ? [...new Set(choices)] : undefined,
179
+ allowOther: allowOther || undefined
180
+ });
181
+ });
182
+
183
+ return result;
184
+ });
185
+
186
+ console.log(`\n🎉 Introspected ${questions.length} fields successfully. Generating schema objects...\n`);
187
+
188
+ const jsonProperties: Record<string, any> = {};
189
+ const jsonRequired: string[] = [];
190
+ const mappingSchema: Record<string, any> = {};
191
+
192
+ for (const q of questions) {
193
+ const key = toCamelCase(q.label);
194
+
195
+ // JSON Schema building
196
+ if (q.type === 'file') {
197
+ jsonProperties[key] = {
198
+ type: 'object',
199
+ properties: {
200
+ fileId: { type: 'string' },
201
+ buffer: { type: 'object' },
202
+ filename: { type: 'string' },
203
+ mimeType: { type: 'string' }
204
+ },
205
+ required: ['filename', 'mimeType']
206
+ };
207
+ } else if (q.type === 'choice') {
208
+ jsonProperties[key] = {
209
+ type: 'string'
210
+ };
211
+ if (q.choices && q.choices.length > 0) {
212
+ jsonProperties[key].enum = q.choices;
213
+ }
214
+ } else {
215
+ jsonProperties[key] = {
216
+ type: 'string'
217
+ };
218
+ if (q.type === 'date') {
219
+ jsonProperties[key].pattern = '^\\d{4}-\\d{2}-\\d{2}$';
220
+ }
221
+ }
222
+
223
+ if (q.required) {
224
+ jsonRequired.push(key);
225
+ }
226
+
227
+ // Mapping Schema building
228
+ mappingSchema[key] = {
229
+ label: q.label,
230
+ type: q.type
231
+ };
232
+ if (q.type === 'choice') {
233
+ mappingSchema[key].options = {
234
+ choices: q.choices || []
235
+ };
236
+ if (q.allowOther) {
237
+ mappingSchema[key].options.allowOther = true;
238
+ }
239
+ }
240
+ }
241
+
242
+ const finalJsonSchema = {
243
+ type: 'object',
244
+ properties: jsonProperties,
245
+ required: jsonRequired
246
+ };
247
+
248
+ // Print TypeScript output
249
+ console.log('// ==========================================');
250
+ console.log('// GENERATED SCHEMAS FOR GOOGLE FORM');
251
+ console.log('// ==========================================');
252
+ console.log('\nexport const JSON_SCHEMA = ' + JSON.stringify(finalJsonSchema, null, 2) + ';\n');
253
+ console.log('export const MAPPING_SCHEMA = ' + JSON.stringify(mappingSchema, null, 2) + ';\n');
254
+
255
+ } finally {
256
+ await context.close();
257
+ await browser.close();
258
+ }
259
+ }
260
+
261
+ main().catch((err) => {
262
+ console.error('❌ Error during form introspection:', err.message);
263
+ process.exit(1);
264
+ });