wikiploy 1.0.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/.eslintrc.cjs +41 -0
- package/.vscode/launch.json +26 -0
- package/DeployConfig.js +21 -0
- package/PageCache.js +134 -0
- package/README.md +7 -0
- package/WikiOps.js +139 -0
- package/Wiki_bot_test.js +23 -0
- package/Wikiploy.js +133 -0
- package/chrome.config.js +10 -0
- package/chromeBase.js +252 -0
- package/package.json +35 -0
- package/test/DeployConfig.test.js +31 -0
- package/test.css +5 -0
- package/test.js +3 -0
package/.eslintrc.cjs
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
module.exports = {
|
|
2
|
+
"env": {
|
|
3
|
+
"node": true,
|
|
4
|
+
"browser": true,
|
|
5
|
+
"es2021": true,
|
|
6
|
+
},
|
|
7
|
+
"globals": {
|
|
8
|
+
"$": true,
|
|
9
|
+
"mw": true,
|
|
10
|
+
},
|
|
11
|
+
"extends": "eslint:recommended",
|
|
12
|
+
"overrides": [{
|
|
13
|
+
"env": {
|
|
14
|
+
"node": true,
|
|
15
|
+
"browser": true,
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
".eslintrc.{js,cjs}"
|
|
19
|
+
],
|
|
20
|
+
"parserOptions": {
|
|
21
|
+
"sourceType": "script"
|
|
22
|
+
}
|
|
23
|
+
}],
|
|
24
|
+
"parserOptions": {
|
|
25
|
+
"ecmaVersion": "latest",
|
|
26
|
+
"sourceType": "module"
|
|
27
|
+
},
|
|
28
|
+
"rules": {
|
|
29
|
+
"no-prototype-builtins": "off",
|
|
30
|
+
"indent": [
|
|
31
|
+
"error",
|
|
32
|
+
"tab",
|
|
33
|
+
{
|
|
34
|
+
"SwitchCase": 1,
|
|
35
|
+
},
|
|
36
|
+
],
|
|
37
|
+
//"array-bracket-newline": ["error", { "multiline": true, "minItems": 3 }],
|
|
38
|
+
//"array-element-newline": ["error", { "multiline": true }]
|
|
39
|
+
"array-element-newline": ["error", "consistent"]
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
// Use IntelliSense to learn about possible attributes.
|
|
3
|
+
// Hover to view descriptions of existing attributes.
|
|
4
|
+
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
|
5
|
+
"version": "0.2.0",
|
|
6
|
+
"configurations": [
|
|
7
|
+
{
|
|
8
|
+
"type": "node",
|
|
9
|
+
"request": "launch",
|
|
10
|
+
"name": "Run current JS",
|
|
11
|
+
"skipFiles": [
|
|
12
|
+
"<node_internals>/**"
|
|
13
|
+
],
|
|
14
|
+
"program": "${workspaceFolder}\\${file}"
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"type": "node",
|
|
18
|
+
"request": "launch",
|
|
19
|
+
"name": "Deploy test.js",
|
|
20
|
+
"skipFiles": [
|
|
21
|
+
"<node_internals>/**"
|
|
22
|
+
],
|
|
23
|
+
"program": "${workspaceFolder}\\Wiki_bot_test.js"
|
|
24
|
+
}
|
|
25
|
+
]
|
|
26
|
+
}
|
package/DeployConfig.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deployment metadata.
|
|
3
|
+
*/
|
|
4
|
+
export default class DeployConfig {
|
|
5
|
+
/**
|
|
6
|
+
* @param {DeployConfig} page Edit page (tab).
|
|
7
|
+
*/
|
|
8
|
+
constructor(options) {
|
|
9
|
+
this.src = options?.src;
|
|
10
|
+
this.dst = options?.dst;
|
|
11
|
+
if (!this.dst) {
|
|
12
|
+
this.dst = `~/${this.src}`;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** info */
|
|
17
|
+
info() {
|
|
18
|
+
return `deploy "${this.src}" to "${this.dst}"`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
}
|
package/PageCache.js
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
|
|
2
|
+
/**
|
|
3
|
+
* Aggressive resource caching.
|
|
4
|
+
*
|
|
5
|
+
* Multiple pages should be able to share this cache.
|
|
6
|
+
*/
|
|
7
|
+
export default class PageCache {
|
|
8
|
+
constructor() {
|
|
9
|
+
this.cache = {};
|
|
10
|
+
this.stats = {
|
|
11
|
+
fromCache: 0,
|
|
12
|
+
direct: 0,
|
|
13
|
+
saved: 0,
|
|
14
|
+
};
|
|
15
|
+
/** Fake max age [s] */
|
|
16
|
+
this.maxAge = 10 * 3600;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Usage info. */
|
|
20
|
+
info() {
|
|
21
|
+
console.log('[PageCache]'
|
|
22
|
+
, 'stats: ' + JSON.stringify(this.stats)
|
|
23
|
+
, 'urls: ' + Object.keys(this.cache).length
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Prepare page (tab) for caching.
|
|
29
|
+
*
|
|
30
|
+
* Note! This should be done before opening a URL (`page.goto`).
|
|
31
|
+
*
|
|
32
|
+
* @param {Page} page
|
|
33
|
+
*/
|
|
34
|
+
async enable(page) {
|
|
35
|
+
const cache = this.cache;
|
|
36
|
+
|
|
37
|
+
// enable requests hijacking (~PWA)
|
|
38
|
+
await page.setRequestInterception(true);
|
|
39
|
+
|
|
40
|
+
// serve from cache
|
|
41
|
+
page.on('request', async (request) => {
|
|
42
|
+
const url = request.url();
|
|
43
|
+
try {
|
|
44
|
+
if (url in cache) {
|
|
45
|
+
const cached = cache[url];
|
|
46
|
+
const method = request.method();
|
|
47
|
+
// if (cached.expires > Date.now()) {
|
|
48
|
+
if (method === 'GET') {
|
|
49
|
+
this.stats.fromCache++;
|
|
50
|
+
await request.respond(cached);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
console.warn('[PageCache]', 'skipped', JSON.stringify({method, url}));
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
} catch (error) {
|
|
58
|
+
console.warn('[PageCache]', 'serving cache failed', url, '\n', error);
|
|
59
|
+
}
|
|
60
|
+
this.stats.direct++;
|
|
61
|
+
request.continue();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// save to cache
|
|
65
|
+
page.on('response', async (response) => {
|
|
66
|
+
const headers = response.headers();
|
|
67
|
+
const url = response.url();
|
|
68
|
+
let cacheit = this.shouldCache(url, headers);
|
|
69
|
+
if (cacheit) {
|
|
70
|
+
if (url in cache) {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
let buffer;
|
|
75
|
+
try {
|
|
76
|
+
buffer = await response.buffer();
|
|
77
|
+
} catch (error) {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// cleanup headers
|
|
82
|
+
delete headers['nel'];
|
|
83
|
+
delete headers['report-to'];
|
|
84
|
+
delete headers['age'];
|
|
85
|
+
delete headers['cache-control'];
|
|
86
|
+
delete headers['set-cookie']; // this brakes puppeteer
|
|
87
|
+
delete headers['server-timing'];
|
|
88
|
+
delete headers['x-cache'];
|
|
89
|
+
delete headers['x-cache-status'];
|
|
90
|
+
|
|
91
|
+
// save
|
|
92
|
+
this.stats.saved++;
|
|
93
|
+
cache[url] = {
|
|
94
|
+
status: response.status(),
|
|
95
|
+
headers,
|
|
96
|
+
body: buffer,
|
|
97
|
+
expires: Date.now() + (this.maxAge * 1000),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Check if we should cache the response.
|
|
105
|
+
*/
|
|
106
|
+
shouldCache(url, headers) {
|
|
107
|
+
const contentType = headers['content-type'] || '';
|
|
108
|
+
if (contentType.startsWith('text/html')) {
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
if (contentType.search(/^text\/(javascript|css)/i) >= 0) {
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const cacheControl = headers['cache-control'] || '';
|
|
116
|
+
if (cacheControl.search(/max-age=[1-9]/) >= 0) {
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// user scripts
|
|
121
|
+
if (url.search(/&ctype=text\/(javascript|css)/) >= 0) {
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
124
|
+
// MediaWiki modules
|
|
125
|
+
if (url.search(/load.+&modules/) >= 0) {
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// console.log(JSON.stringify(headers, null, '\t'));
|
|
130
|
+
// console.log(url);
|
|
131
|
+
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
}
|
package/README.md
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
Wikiploy
|
|
2
|
+
==========================
|
|
3
|
+
|
|
4
|
+
User scripts and gadgets deployment for wikis (Wikipedia or more generally MediaWiki based wiki).
|
|
5
|
+
Rollout your JS, CSS etc from your git repository to as many MW wikis as you need.
|
|
6
|
+
|
|
7
|
+
This is using [Puppeteer](https://pptr.dev/) to control [Chrome Canary](https://www.google.com/chrome/canary/). You just open Chrome and run a script. The idea is that you are logged in in Chrome and so all edits are still your edits. You can keep the Canary running in the background when you are changing and deploying more stuff.
|
package/WikiOps.js
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import {
|
|
2
|
+
scrollIntoViewIfNeeded,
|
|
3
|
+
waitForSelector,
|
|
4
|
+
waitForSelectors,
|
|
5
|
+
} from './chromeBase.js'
|
|
6
|
+
|
|
7
|
+
import PageCache from './PageCache.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Helper class for edits and navigation.
|
|
11
|
+
*
|
|
12
|
+
* Note! Due to Chrome's large memory consupmtion on new tabs it is best to re-use tabs.
|
|
13
|
+
* So it is best to open tab before loop:
|
|
14
|
+
// open new tab
|
|
15
|
+
const page = await wikiBot.openTab(browser);
|
|
16
|
+
// some loop
|
|
17
|
+
for () {
|
|
18
|
+
await page.goto(url);
|
|
19
|
+
...do stuff...
|
|
20
|
+
}
|
|
21
|
+
page.close();
|
|
22
|
+
*/
|
|
23
|
+
export default class WikiOps {
|
|
24
|
+
/**
|
|
25
|
+
* Init.
|
|
26
|
+
* @param {PageCache} globalCache Cache for page resources.
|
|
27
|
+
*/
|
|
28
|
+
constructor(globalCache) {
|
|
29
|
+
this.cache = globalCache ? globalCache : new PageCache();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Open new tab.
|
|
34
|
+
* @returns {Browser} browser.
|
|
35
|
+
*/
|
|
36
|
+
async openTab(browser) {
|
|
37
|
+
const page = await browser.newPage();
|
|
38
|
+
await this.initViewport(page);
|
|
39
|
+
await this.cache.enable(page);
|
|
40
|
+
await this.disarmUnloadWarning(page);
|
|
41
|
+
const timeout = 5000;
|
|
42
|
+
page.setDefaultTimeout(timeout);
|
|
43
|
+
return page;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Avoid leave-page warning.
|
|
48
|
+
* @param {Page} page
|
|
49
|
+
* @private
|
|
50
|
+
*/
|
|
51
|
+
async disarmUnloadWarning(page) {
|
|
52
|
+
// force to accept the warning
|
|
53
|
+
page.on("dialog", (dialog) => {
|
|
54
|
+
if (dialog.type() === "beforeunload") {
|
|
55
|
+
dialog.accept();
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
await page.evaluate(() => {
|
|
59
|
+
window.onbeforeunload = null;
|
|
60
|
+
window.addEventListener("load", () => {
|
|
61
|
+
$(window).off('beforeunload');
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Init view.
|
|
68
|
+
* @param {Page} targetPage
|
|
69
|
+
* @private
|
|
70
|
+
*/
|
|
71
|
+
async initViewport(targetPage) {
|
|
72
|
+
await targetPage.setViewport({
|
|
73
|
+
width: 1200,
|
|
74
|
+
height: 900
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Save. */
|
|
79
|
+
async saveEdit(targetPage) {
|
|
80
|
+
const timeout = 200;
|
|
81
|
+
|
|
82
|
+
await scrollIntoViewIfNeeded([
|
|
83
|
+
'#wpSave'
|
|
84
|
+
], targetPage, timeout);
|
|
85
|
+
const element = await waitForSelectors([
|
|
86
|
+
'#wpSave'
|
|
87
|
+
], targetPage, {
|
|
88
|
+
timeout,
|
|
89
|
+
visible: true
|
|
90
|
+
});
|
|
91
|
+
let nav = targetPage.waitForNavigation(); // init wait
|
|
92
|
+
await element.click({
|
|
93
|
+
offset: {
|
|
94
|
+
x: 10,
|
|
95
|
+
y: 4,
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
await nav; // wait for form submit
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Go to url (and wait for it). */
|
|
102
|
+
async goto(page, url) {
|
|
103
|
+
let nav = page.waitForNavigation(); // init wait
|
|
104
|
+
await page.goto(url);
|
|
105
|
+
await nav; // wait for url
|
|
106
|
+
await this.disarmUnloadWarning(page);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Change intput's value. */
|
|
110
|
+
async fillEdit(page, value) {
|
|
111
|
+
const timeout = 5000;
|
|
112
|
+
await waitForSelector('#editform', page, {
|
|
113
|
+
timeout,
|
|
114
|
+
});
|
|
115
|
+
// await changeElementValue(element, value);
|
|
116
|
+
await page.evaluate((value) => {
|
|
117
|
+
// remove editors (plain, WYSIWYG)
|
|
118
|
+
document.querySelectorAll('#editform textarea, .ace_editor').forEach(el=>el.remove());
|
|
119
|
+
// add plain textarea
|
|
120
|
+
document.querySelector('#editform').insertAdjacentHTML('afterbegin', `
|
|
121
|
+
<textarea id="wpTextbox1" name="wpTextbox1"
|
|
122
|
+
></textarea>
|
|
123
|
+
`);
|
|
124
|
+
// insert value
|
|
125
|
+
let input = document.querySelector('#wpTextbox1');
|
|
126
|
+
input.value = value;
|
|
127
|
+
}, value);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Insert summary. */
|
|
131
|
+
async fillSummary(page, summary) {
|
|
132
|
+
await page.evaluate((summary) => {
|
|
133
|
+
let wpSummary = document.querySelector('#wpSummary');
|
|
134
|
+
wpSummary.value = summary;
|
|
135
|
+
}, summary);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
}
|
package/Wiki_bot_test.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import DeployConfig from './DeployConfig.js';
|
|
2
|
+
import Wikiploy from './Wikiploy.js';
|
|
3
|
+
|
|
4
|
+
const ployBot = new Wikiploy();
|
|
5
|
+
// mock
|
|
6
|
+
// ployBot.mock = true;
|
|
7
|
+
// ployBot.mockSleep = 5_000;
|
|
8
|
+
|
|
9
|
+
(async () => {
|
|
10
|
+
const configs = [];
|
|
11
|
+
configs.push(new DeployConfig({
|
|
12
|
+
src: 'test.js',
|
|
13
|
+
dst: 'User:Nux/test-jsbot--test.js',
|
|
14
|
+
}));
|
|
15
|
+
configs.push(new DeployConfig({
|
|
16
|
+
src: 'test.css',
|
|
17
|
+
dst: 'User:Nux/test-jsbot--test.css',
|
|
18
|
+
}));
|
|
19
|
+
await ployBot.deploy(configs);
|
|
20
|
+
})().catch(err => {
|
|
21
|
+
console.error(err);
|
|
22
|
+
process.exit(1);
|
|
23
|
+
});
|
package/Wikiploy.js
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import puppeteer, { Browser } from 'puppeteer'; // v13+
|
|
2
|
+
|
|
3
|
+
import WikiOps from './WikiOps.js';
|
|
4
|
+
import PageCache from './PageCache.js';
|
|
5
|
+
|
|
6
|
+
import { promises as fs } from "fs"; // node v11+
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
wsBrowserPort,
|
|
10
|
+
} from './chrome.config.js'
|
|
11
|
+
|
|
12
|
+
// eslint-disable-next-line no-unused-vars
|
|
13
|
+
function sleep(sleepMs) {
|
|
14
|
+
return new Promise((resolve)=>{setTimeout(()=>resolve(), sleepMs)});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* MediaWiki deployment automation.
|
|
19
|
+
*
|
|
20
|
+
* @property _browser {Browser} The user's email
|
|
21
|
+
*/
|
|
22
|
+
export default class Wikiploy {
|
|
23
|
+
constructor() {
|
|
24
|
+
this.cache = new PageCache();
|
|
25
|
+
/** Disable save. */
|
|
26
|
+
this.mock = false;
|
|
27
|
+
/** Wait before close [ms] (or you can set a breakpoint to check stuff). */
|
|
28
|
+
this.mockSleep = 0;
|
|
29
|
+
|
|
30
|
+
/** Browser connection. */
|
|
31
|
+
this._browser = false;
|
|
32
|
+
|
|
33
|
+
/** Bot helper. */
|
|
34
|
+
this._bot = new WikiOps(this.cache);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Deploy scripts.
|
|
39
|
+
* @param {DeployConfig[]} configs
|
|
40
|
+
*/
|
|
41
|
+
async deploy(configs) {
|
|
42
|
+
const bot = this._bot;
|
|
43
|
+
const browser = await this.init();
|
|
44
|
+
const page = await bot.openTab(browser);
|
|
45
|
+
console.log(JSON.stringify(configs));
|
|
46
|
+
// main loop
|
|
47
|
+
for (const config of configs) {
|
|
48
|
+
await this.save(config, page);
|
|
49
|
+
}
|
|
50
|
+
page.close();
|
|
51
|
+
console.log(`done`);
|
|
52
|
+
process.exit(0);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Deploy script.
|
|
57
|
+
* @param {DeployConfig} config Config.
|
|
58
|
+
* @param {Page} page
|
|
59
|
+
*/
|
|
60
|
+
async save(config, page) {
|
|
61
|
+
console.log('[Wikiploy]', config.info());
|
|
62
|
+
const bot = this._bot;
|
|
63
|
+
// navigate
|
|
64
|
+
let url = this.editUrl(config.dst);
|
|
65
|
+
await bot.goto(page, url);
|
|
66
|
+
// insert the content of the file into the edit field
|
|
67
|
+
const contents = await fs.readFile(config.src, 'utf8');
|
|
68
|
+
await bot.fillEdit(page, contents);
|
|
69
|
+
// edit description
|
|
70
|
+
const summary = this.preapreSummary(config);
|
|
71
|
+
await bot.fillSummary(page, summary);
|
|
72
|
+
|
|
73
|
+
// save
|
|
74
|
+
if (!this.mock) {
|
|
75
|
+
await bot.saveEdit(page);
|
|
76
|
+
console.log('saved');
|
|
77
|
+
} else {
|
|
78
|
+
await sleep(this.mockSleep);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Prepare edit summary.
|
|
85
|
+
*
|
|
86
|
+
* @param {String} pageTitle Title with namespace.
|
|
87
|
+
* @returns {String} Full edit URL.
|
|
88
|
+
* @private
|
|
89
|
+
*/
|
|
90
|
+
preapreSummary(config) {
|
|
91
|
+
return '[Wikiploy]' + ` ${config.src}`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Prepare edit URL.
|
|
96
|
+
*
|
|
97
|
+
* @param {String} pageTitle Title with namespace.
|
|
98
|
+
* @returns {String} Full edit URL.
|
|
99
|
+
* @private
|
|
100
|
+
*/
|
|
101
|
+
editUrl(pageTitle) {
|
|
102
|
+
const baseUrl = `https://pl.wikipedia.org/w/index.php`; // TODO: config/options
|
|
103
|
+
|
|
104
|
+
// common params
|
|
105
|
+
// note that submit action is not affected by new wikicode editor
|
|
106
|
+
let params = `
|
|
107
|
+
&useskin=monobook
|
|
108
|
+
&action=submit
|
|
109
|
+
`.replace(/\s+/g, '');
|
|
110
|
+
|
|
111
|
+
return baseUrl + '?title=' + encodeURIComponent(pageTitle) + params;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Init browser connection.
|
|
116
|
+
*
|
|
117
|
+
* @returns {Browser} the browser API.
|
|
118
|
+
*/
|
|
119
|
+
async init() {
|
|
120
|
+
if (this._browser instanceof Browser) {
|
|
121
|
+
return this._browser;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// connect to current (open) Chrome window
|
|
125
|
+
const browserURL = `http://127.0.0.1:${wsBrowserPort}`;
|
|
126
|
+
const browser = await puppeteer.connect({
|
|
127
|
+
// browserWSEndpoint: wsUrl,
|
|
128
|
+
browserURL,
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
return browser;
|
|
132
|
+
}
|
|
133
|
+
}
|
package/chrome.config.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chrome configuration.
|
|
3
|
+
*
|
|
4
|
+
* You should have a Canary edition of Chrome. It seems easiest to setup doesn't involve your main Chrome.
|
|
5
|
+
*
|
|
6
|
+
* Then just run Chrome Canary with param: "...\chrome.exe" --remote-debugging-port=9222
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Remote debugging port. */
|
|
10
|
+
export const wsBrowserPort = '9222';
|
package/chromeBase.js
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/* eslint-disable no-undef */
|
|
2
|
+
/* eslint-disable indent */
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Functions provided by Chrome Recorder.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export async function waitForSelectors(selectors, frame, options) {
|
|
9
|
+
for (const selector of selectors) {
|
|
10
|
+
try {
|
|
11
|
+
return await waitForSelector(selector, frame, options);
|
|
12
|
+
} catch (err) {
|
|
13
|
+
console.error(err);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
throw new Error('Could not find element for selectors: ' + JSON.stringify(selectors));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function scrollIntoViewIfNeeded(selectors, frame, timeout) {
|
|
20
|
+
const element = await waitForSelectors(selectors, frame, {
|
|
21
|
+
visible: false,
|
|
22
|
+
timeout
|
|
23
|
+
});
|
|
24
|
+
if (!element) {
|
|
25
|
+
throw new Error(
|
|
26
|
+
'The element could not be found.'
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
await waitForConnected(element, timeout);
|
|
30
|
+
const isInViewport = await element.isIntersectingViewport({
|
|
31
|
+
threshold: 0
|
|
32
|
+
});
|
|
33
|
+
if (isInViewport) {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
await element.evaluate(element => {
|
|
37
|
+
element.scrollIntoView({
|
|
38
|
+
block: 'center',
|
|
39
|
+
inline: 'center',
|
|
40
|
+
behavior: 'auto',
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
await waitForInViewport(element, timeout);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function waitForConnected(element, timeout) {
|
|
47
|
+
await waitForFunction(async () => {
|
|
48
|
+
return await element.getProperty('isConnected');
|
|
49
|
+
}, timeout);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function waitForInViewport(element, timeout) {
|
|
53
|
+
await waitForFunction(async () => {
|
|
54
|
+
return await element.isIntersectingViewport({
|
|
55
|
+
threshold: 0
|
|
56
|
+
});
|
|
57
|
+
}, timeout);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function waitForSelector(selector, frame, options) {
|
|
61
|
+
if (!Array.isArray(selector)) {
|
|
62
|
+
selector = [selector];
|
|
63
|
+
}
|
|
64
|
+
if (!selector.length) {
|
|
65
|
+
throw new Error('Empty selector provided to waitForSelector');
|
|
66
|
+
}
|
|
67
|
+
let element = null;
|
|
68
|
+
for (let i = 0; i < selector.length; i++) {
|
|
69
|
+
const part = selector[i];
|
|
70
|
+
if (element) {
|
|
71
|
+
element = await element.waitForSelector(part, options);
|
|
72
|
+
} else {
|
|
73
|
+
element = await frame.waitForSelector(part, options);
|
|
74
|
+
}
|
|
75
|
+
if (!element) {
|
|
76
|
+
throw new Error('Could not find element: ' + selector.join('>>'));
|
|
77
|
+
}
|
|
78
|
+
if (i < selector.length - 1) {
|
|
79
|
+
element = (await element.evaluateHandle(el => el.shadowRoot ? el.shadowRoot : el)).asElement();
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (!element) {
|
|
83
|
+
throw new Error('Could not find element: ' + selector.join('|'));
|
|
84
|
+
}
|
|
85
|
+
return element;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function waitForElement(step, frame, timeout) {
|
|
89
|
+
const {
|
|
90
|
+
count = 1,
|
|
91
|
+
operator = '>=',
|
|
92
|
+
visible = true,
|
|
93
|
+
properties,
|
|
94
|
+
attributes,
|
|
95
|
+
} = step;
|
|
96
|
+
const compFn = {
|
|
97
|
+
'==': (a, b) => a === b,
|
|
98
|
+
'>=': (a, b) => a >= b,
|
|
99
|
+
'<=': (a, b) => a <= b,
|
|
100
|
+
} [operator];
|
|
101
|
+
await waitForFunction(async () => {
|
|
102
|
+
const elements = await querySelectorsAll(step.selectors, frame);
|
|
103
|
+
let result = compFn(elements.length, count);
|
|
104
|
+
const elementsHandle = await frame.evaluateHandle((...elements) => {
|
|
105
|
+
return elements;
|
|
106
|
+
}, ...elements);
|
|
107
|
+
await Promise.all(elements.map((element) => element.dispose()));
|
|
108
|
+
if (result && (properties || attributes)) {
|
|
109
|
+
result = await elementsHandle.evaluate(
|
|
110
|
+
(elements, properties, attributes) => {
|
|
111
|
+
for (const element of elements) {
|
|
112
|
+
if (attributes) {
|
|
113
|
+
for (const [name, value] of Object.entries(attributes)) {
|
|
114
|
+
if (element.getAttribute(name) !== value) {
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (properties) {
|
|
120
|
+
if (!isDeepMatch(properties, element)) {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return true;
|
|
126
|
+
|
|
127
|
+
function isDeepMatch(a, b) {
|
|
128
|
+
if (a === b) {
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
if ((a && !b) || (!a && b)) {
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
if (!(a instanceof Object) || !(b instanceof Object)) {
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
for (const [key, value] of Object.entries(a)) {
|
|
138
|
+
if (!isDeepMatch(value, b[key])) {
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
},
|
|
145
|
+
properties,
|
|
146
|
+
attributes
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
await elementsHandle.dispose();
|
|
150
|
+
return result === visible;
|
|
151
|
+
}, timeout);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export async function querySelectorsAll(selectors, frame) {
|
|
155
|
+
for (const selector of selectors) {
|
|
156
|
+
const result = await querySelectorAll(selector, frame);
|
|
157
|
+
if (result.length) {
|
|
158
|
+
return result;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return [];
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export async function querySelectorAll(selector, frame) {
|
|
165
|
+
if (!Array.isArray(selector)) {
|
|
166
|
+
selector = [selector];
|
|
167
|
+
}
|
|
168
|
+
if (!selector.length) {
|
|
169
|
+
throw new Error('Empty selector provided to querySelectorAll');
|
|
170
|
+
}
|
|
171
|
+
let elements = [];
|
|
172
|
+
for (let i = 0; i < selector.length; i++) {
|
|
173
|
+
const part = selector[i];
|
|
174
|
+
if (i === 0) {
|
|
175
|
+
elements = await frame.$$(part);
|
|
176
|
+
} else {
|
|
177
|
+
const tmpElements = elements;
|
|
178
|
+
elements = [];
|
|
179
|
+
for (const el of tmpElements) {
|
|
180
|
+
elements.push(...(await el.$$(part)));
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
if (elements.length === 0) {
|
|
184
|
+
return [];
|
|
185
|
+
}
|
|
186
|
+
if (i < selector.length - 1) {
|
|
187
|
+
const tmpElements = [];
|
|
188
|
+
for (const el of elements) {
|
|
189
|
+
const newEl = (await el.evaluateHandle(el => el.shadowRoot ? el.shadowRoot : el)).asElement();
|
|
190
|
+
if (newEl) {
|
|
191
|
+
tmpElements.push(newEl);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
elements = tmpElements;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return elements;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export async function waitForFunction(fn, timeout) {
|
|
201
|
+
let isActive = true;
|
|
202
|
+
const timeoutId = setTimeout(() => {
|
|
203
|
+
isActive = false;
|
|
204
|
+
}, timeout);
|
|
205
|
+
while (isActive) {
|
|
206
|
+
const result = await fn();
|
|
207
|
+
if (result) {
|
|
208
|
+
clearTimeout(timeoutId);
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
212
|
+
}
|
|
213
|
+
throw new Error('Timed out');
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export async function changeSelectElement(element, value) {
|
|
217
|
+
await element.select(value);
|
|
218
|
+
await element.evaluateHandle((e) => {
|
|
219
|
+
e.blur();
|
|
220
|
+
e.focus();
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export async function changeElementValue(element, value) {
|
|
225
|
+
await element.focus();
|
|
226
|
+
await element.evaluate((input, value) => {
|
|
227
|
+
input.value = value;
|
|
228
|
+
input.dispatchEvent(new Event('input', {
|
|
229
|
+
bubbles: true
|
|
230
|
+
}));
|
|
231
|
+
input.dispatchEvent(new Event('change', {
|
|
232
|
+
bubbles: true
|
|
233
|
+
}));
|
|
234
|
+
}, value);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export async function typeIntoElement(element, value) {
|
|
238
|
+
const textToType = await element.evaluate((input, newValue) => {
|
|
239
|
+
if (
|
|
240
|
+
newValue.length <= input.value.length ||
|
|
241
|
+
!newValue.startsWith(input.value)
|
|
242
|
+
) {
|
|
243
|
+
input.value = '';
|
|
244
|
+
return newValue;
|
|
245
|
+
}
|
|
246
|
+
const originalValue = input.value;
|
|
247
|
+
input.value = '';
|
|
248
|
+
input.value = originalValue;
|
|
249
|
+
return newValue.substring(originalValue.length);
|
|
250
|
+
}, value);
|
|
251
|
+
await element.type(textToType);
|
|
252
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "wikiploy",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "User scripts and gadgets deployment for MediaWiki (Wikipedia).",
|
|
5
|
+
"main": "Wiki_bot_test.js",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"test": "echo \"Error: no test specified\" && exit 1"
|
|
9
|
+
},
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "git+https://github.com/Eccenux/Wikiploy.git"
|
|
13
|
+
},
|
|
14
|
+
"keywords": [
|
|
15
|
+
"Mediawiki",
|
|
16
|
+
"Wikipedia",
|
|
17
|
+
"deploy",
|
|
18
|
+
"rollout",
|
|
19
|
+
"gadgets"
|
|
20
|
+
],
|
|
21
|
+
"author": "Maciej Nux Jaros",
|
|
22
|
+
"license": "MIT",
|
|
23
|
+
"bugs": {
|
|
24
|
+
"url": "https://github.com/Eccenux/Wikiploy/issues"
|
|
25
|
+
},
|
|
26
|
+
"homepage": "https://github.com/Eccenux/Wikiploy#readme",
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"puppeteer": "^20.7.2"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"chai": "^4.3.7",
|
|
32
|
+
"mocha": "^10.2.0",
|
|
33
|
+
"eslint": "^8.43.0"
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/* global describe, it */
|
|
2
|
+
import { assert } from 'chai';
|
|
3
|
+
import DeployConfig from '../DeployConfig.js';
|
|
4
|
+
|
|
5
|
+
describe('DeployConfig', function () {
|
|
6
|
+
|
|
7
|
+
describe('init', function () {
|
|
8
|
+
it('should set src, dst', function () {
|
|
9
|
+
let config = {
|
|
10
|
+
src: 'test.src.js',
|
|
11
|
+
dst: 'User:Nux/test.dst.js',
|
|
12
|
+
};
|
|
13
|
+
let result = new DeployConfig(config);
|
|
14
|
+
console.log(result);
|
|
15
|
+
assert.equal(result.src, config.src);
|
|
16
|
+
assert.equal(result.dst, config.dst);
|
|
17
|
+
// console.log(new DeployConfig({
|
|
18
|
+
// src: 'test.js',
|
|
19
|
+
// }));
|
|
20
|
+
});
|
|
21
|
+
it('should default to home', function () {
|
|
22
|
+
let config = {
|
|
23
|
+
src: 'test.js',
|
|
24
|
+
};
|
|
25
|
+
let expected = '~/test.js';
|
|
26
|
+
let result = new DeployConfig(config);
|
|
27
|
+
console.log(result);
|
|
28
|
+
assert.equal(result.dst, expected);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
});
|
package/test.css
ADDED
package/test.js
ADDED