mirror of
https://github.com/internetarchive/heritrix3.git
synced 2026-09-23 14:15:47 +00:00
Merge pull request #601 from internetarchive/remove-extractor-chrome
Remove ExtractorChrome
This commit is contained in:
@@ -1,435 +0,0 @@
|
||||
/*
|
||||
* This file is part of the Heritrix web crawler (crawler.archive.org).
|
||||
*
|
||||
* Licensed to the Internet Archive (IA) by one or more individual
|
||||
* contributors.
|
||||
*
|
||||
* The IA licenses this file to You under the Apache License, Version 2.0
|
||||
* (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.archive.modules.extractor;
|
||||
|
||||
import org.apache.commons.io.IOUtils;
|
||||
import org.archive.crawler.event.CrawlURIDispositionEvent;
|
||||
import org.archive.crawler.framework.CrawlController;
|
||||
import org.archive.crawler.framework.Frontier;
|
||||
import org.archive.modules.CrawlURI;
|
||||
import org.archive.modules.Processor;
|
||||
import org.archive.modules.ProcessorChain;
|
||||
import org.archive.net.chrome.*;
|
||||
import org.archive.spring.KeyedProperties;
|
||||
import org.archive.util.Recorder;
|
||||
import org.archive.util.UriUtils;
|
||||
import org.json.JSONArray;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
|
||||
import java.io.*;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.Semaphore;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import java.util.logging.Logger;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import static java.nio.charset.StandardCharsets.US_ASCII;
|
||||
import static java.util.Collections.enumeration;
|
||||
import static java.util.logging.Level.*;
|
||||
import static java.util.regex.Pattern.CASE_INSENSITIVE;
|
||||
import static org.archive.crawler.event.CrawlURIDispositionEvent.Disposition.FAILED;
|
||||
import static org.archive.crawler.event.CrawlURIDispositionEvent.Disposition.SUCCEEDED;
|
||||
import static org.archive.modules.CoreAttributeConstants.A_HTTP_RESPONSE_HEADERS;
|
||||
import static org.archive.modules.CrawlURI.FetchType.*;
|
||||
|
||||
/**
|
||||
* Extracts links using a web browser via the Chrome Devtools Protocol.
|
||||
* <p>
|
||||
* To use, first define this as a top-level bean:
|
||||
* <pre>
|
||||
* <bean id="extractorChrome" class="org.archive.modules.extractor.ExtractorChrome">
|
||||
* <!-- <property name="captureRequests" value="true" /> -->
|
||||
* <!-- <property name="devtoolsUrl" value="ws://127.0.0.1:1234/devtools/browser/2bc831e8-6c02-4c9b-affd-14c93b8579d7" /> -->
|
||||
* <!-- <property name="executable" value="chromium-browser" /> -->
|
||||
* <!-- <property name="loadTimeoutSeconds" value="30" /> -->
|
||||
* <!-- <property name="maxOpenWindows" value="16" /> -->
|
||||
* <!-- <property name="windowWidth" value="1366" /> -->
|
||||
* <!-- <property name="windowWidth" value="768" /> -->
|
||||
* </bean>
|
||||
* </pre>
|
||||
* Then add <code><ref bean="extractorChrome"/></code> to the fetch chain before <code>extractorHTML</code>.
|
||||
* <p>
|
||||
* By default an instance of the browser will be run as a subprocess for the duration of the crawl. Alternatively set
|
||||
* <code>devtoolsUrl</code> to connect to an existing instance of the browser (run with
|
||||
* <code>--headless --remote-debugging-port=1234</code>).
|
||||
*/
|
||||
public class ExtractorChrome extends ContentExtractor {
|
||||
private static final Logger logger = Logger.getLogger(ExtractorChrome.class.getName());
|
||||
private static final AtomicLong nextRecorderId = new AtomicLong();
|
||||
|
||||
private static final Pattern TRANSFER_ENCODING_RE = Pattern.compile("\r\nTransfer-Encoding:[^\n\r]+", CASE_INSENSITIVE);
|
||||
|
||||
/**
|
||||
* The maximum number of browser windows that are allowed to be opened simultaneously. Feel free to increase this
|
||||
* if you have lots of RAM available.
|
||||
*/
|
||||
private int maxOpenWindows = 16;
|
||||
|
||||
/**
|
||||
* URL of the devtools server to connect. If null a new browser process will be launched.
|
||||
*/
|
||||
private String devtoolsUrl = null;
|
||||
|
||||
/**
|
||||
* The name or path to the browser executable. If null common locations will be searched. Not used if devtoolsUrl
|
||||
* is set.
|
||||
*/
|
||||
private String executable = null;
|
||||
|
||||
/**
|
||||
* Extra command-line options passed to the browser process. Not used if devtoolsUrl is null.
|
||||
*/
|
||||
private List<String> commandLineOptions = new ArrayList<>();
|
||||
|
||||
/**
|
||||
* Width of the browser window.
|
||||
*/
|
||||
private int windowWidth = 1366;
|
||||
|
||||
/**
|
||||
* Height of the browser window.
|
||||
*/
|
||||
private int windowHeight = 768;
|
||||
|
||||
/**
|
||||
* Number of seconds to wait for the page to load.
|
||||
*/
|
||||
private int loadTimeoutSeconds = 30;
|
||||
|
||||
/**
|
||||
* Capture requests made by the browser.
|
||||
*/
|
||||
private boolean captureRequests = true;
|
||||
|
||||
/**
|
||||
* The maximum size response body that can be replayed to the browser. Setting this to -1 will cause all requests by
|
||||
* the browser to be made against the live web.
|
||||
*/
|
||||
private int maxReplayLength = 100 * 1024 * 1024;
|
||||
|
||||
private Semaphore openWindowsSemaphore = null;
|
||||
private ChromeProcess process = null;
|
||||
private ChromeClient client = null;
|
||||
|
||||
private final CrawlController controller;
|
||||
private final ApplicationEventPublisher eventPublisher;
|
||||
private ProcessorChain extractorChain;
|
||||
|
||||
public ExtractorChrome(CrawlController controller, ApplicationEventPublisher eventPublisher) {
|
||||
this.controller = controller;
|
||||
this.eventPublisher = eventPublisher;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean shouldExtract(CrawlURI uri) {
|
||||
return uri.getContentType().startsWith("text/html") && uri.is2XXSuccess();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean innerExtract(CrawlURI uri) {
|
||||
ensureConnected();
|
||||
try {
|
||||
openWindowsSemaphore.acquire();
|
||||
try {
|
||||
visit(uri);
|
||||
} finally {
|
||||
openWindowsSemaphore.release();
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private void visit(CrawlURI curi) throws InterruptedException {
|
||||
try (ChromeWindow window = client.createWindow(windowWidth, windowHeight)) {
|
||||
window.interceptRequests(request -> handleInterceptedRequest(curi, request));
|
||||
|
||||
if (captureRequests) {
|
||||
window.captureRequests(request -> handleCapturedRequest(curi, request));
|
||||
}
|
||||
|
||||
try {
|
||||
window.navigateAsync(curi.getURI()).get(loadTimeoutSeconds, TimeUnit.SECONDS);
|
||||
} catch (ExecutionException e) {
|
||||
throw new RuntimeException(e.getCause());
|
||||
} catch (TimeoutException e) {
|
||||
throw new RuntimeException("Timed out navigating to " + curi.getURI());
|
||||
}
|
||||
|
||||
JSONArray links = window.eval("Array.from(document.querySelectorAll('a[href], area[href]'))" +
|
||||
".map(link => link.protocol + '//' + link.host + link.pathname + link.search + link.hash)")
|
||||
.getJSONArray("value");
|
||||
for (int i = 0; i < links.length(); i++) {
|
||||
addOutlink(curi, links.getString(i), LinkContext.NAVLINK_MISC, Hop.NAVLINK);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void handleInterceptedRequest(CrawlURI curi, InterceptedRequest interceptedRequest) {
|
||||
ChromeRequest request = interceptedRequest.getRequest();
|
||||
if (request.getMethod().equals("GET") && request.getUrl().equals(curi.getURI())) {
|
||||
replayResponseToBrowser(curi, interceptedRequest);
|
||||
} else {
|
||||
interceptedRequest.continueNormally();
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private void replayResponseToBrowser(CrawlURI curi, InterceptedRequest interceptedRequest) {
|
||||
// There seems to be no easy way to stream the body to the browser so we slurp it into
|
||||
// memory with a size limit. The one way I can see to achieve streaming is to have Heritrix
|
||||
// serve the request over its HTTP server and pass a Heritrix URL to Fetch.fulfillRequest
|
||||
// instead of the body directly. We might need to do that if memory pressure becomes a
|
||||
// problem but for now just keep it simple.
|
||||
|
||||
long bodyLength = curi.getRecorder().getResponseContentLength();
|
||||
if (bodyLength > maxReplayLength) {
|
||||
logger.log(FINE, "Page body too large to replay: {0}", curi.getURI());
|
||||
interceptedRequest.continueNormally();
|
||||
return;
|
||||
}
|
||||
|
||||
byte[] body = new byte[(int)bodyLength];
|
||||
try (InputStream stream = curi.getRecorder().getContentReplayInputStream()) {
|
||||
IOUtils.readFully(stream, body);
|
||||
} catch (IOException e) {
|
||||
logger.log(WARNING, "Error reading back page body: " + curi.getURI(), e);
|
||||
interceptedRequest.continueNormally();
|
||||
return;
|
||||
}
|
||||
|
||||
Map<String,String> headers = (Map<String, String>) curi.getData().get(A_HTTP_RESPONSE_HEADERS);
|
||||
if (headers == null) {
|
||||
logger.log(WARNING, "Response headers unavailable in CrawlURI. Letting the browser " +
|
||||
"refetch {0}", curi.getURI());
|
||||
interceptedRequest.continueNormally();
|
||||
return;
|
||||
}
|
||||
interceptedRequest.fulfill(curi.getFetchStatus(), headers.entrySet(), body);
|
||||
}
|
||||
|
||||
private void handleCapturedRequest(CrawlURI via, ChromeRequest request) {
|
||||
if (request.isResponseFulfilledByInterception() || UriUtils.isDataUri(request.getUrl())) {
|
||||
return;
|
||||
}
|
||||
|
||||
String recorderBaseName = "ExtractorChrome-" + nextRecorderId.getAndIncrement();
|
||||
Recorder recorder = new Recorder(new File(controller.getScratchDir().getFile(), recorderBaseName),
|
||||
controller.getRecorderOutBufferBytes(), controller.getRecorderInBufferBytes());
|
||||
try {
|
||||
String digestAlgorithm = "sha1";
|
||||
recorder.getRecordedInput().setDigest(digestAlgorithm);
|
||||
recorder.getRecordedOutput().write(request.getRequestHeader().getBytes(US_ASCII));
|
||||
recorder.getRecordedOutput().write(request.getRequestBody());
|
||||
|
||||
// strip the Transfer-Encoding header since we only have access to the decoded body
|
||||
String responseHeader = TRANSFER_ENCODING_RE.matcher(request.getResponseHeader()).replaceAll("");
|
||||
|
||||
recorder.inputWrap(new SequenceInputStream(enumeration(Arrays.asList(
|
||||
new ByteArrayInputStream(responseHeader.getBytes(US_ASCII)),
|
||||
new InputStream() {
|
||||
public int read() {
|
||||
recorder.markContentBegin();
|
||||
return -1;
|
||||
}
|
||||
},
|
||||
new ByteArrayInputStream(request.getResponseBody())))));
|
||||
recorder.getRecordedInput().readFully();
|
||||
recorder.closeRecorders();
|
||||
|
||||
CrawlURI curi = via.createCrawlURI(request.getUrl(), LinkContext.EMBED_MISC, Hop.EMBED);
|
||||
curi.getAnnotations().add("browser");
|
||||
curi.setContentDigest(digestAlgorithm, recorder.getRecordedInput().getDigestValue());
|
||||
curi.setContentSize(recorder.getRecordedInput().getSize());
|
||||
curi.setContentType(request.getResponseContentType());
|
||||
curi.setFetchBeginTime(request.getBeginTime());
|
||||
curi.setFetchCompletedTime(System.currentTimeMillis());
|
||||
curi.setFetchStatus(request.getStatus());
|
||||
curi.setRecorder(recorder);
|
||||
curi.setServerIP(request.getRemoteIPAddress());
|
||||
curi.setThreadNumber(via.getThreadNumber());
|
||||
|
||||
switch (request.getMethod()) {
|
||||
case "GET":
|
||||
curi.setFetchType(HTTP_GET);
|
||||
break;
|
||||
case "POST":
|
||||
curi.setFetchType(HTTP_POST);
|
||||
break;
|
||||
default:
|
||||
curi.setFetchType(UNKNOWN);
|
||||
break;
|
||||
}
|
||||
|
||||
Frontier frontier = controller.getFrontier();
|
||||
curi.getOverlayNames(); // for side-effect of creating the overlayNames list
|
||||
|
||||
// inform the frontier we've already seen this uri so it won't schedule it
|
||||
// we only do this for GETs so a POST doesn't prevent scheduling a GET of the same URI
|
||||
if (request.getMethod().equals("GET")) {
|
||||
frontier.considerIncluded(curi);
|
||||
}
|
||||
|
||||
KeyedProperties.loadOverridesFrom(curi);
|
||||
try {
|
||||
// perform link extraction
|
||||
extractorChain.process(curi, null);
|
||||
|
||||
// send the result to the disposition chain to dispatch outlinks and write warcs
|
||||
frontier.beginDisposition(curi);
|
||||
controller.getDispositionChain().process(curi,null);
|
||||
} finally {
|
||||
KeyedProperties.clearOverridesFrom(curi);
|
||||
}
|
||||
|
||||
curi.aboutToLog();
|
||||
controller.getLoggerModule().getUriProcessing().log(INFO, curi.getUURI().toString(), curi);
|
||||
|
||||
if (curi.isSuccess()) {
|
||||
eventPublisher.publishEvent(new CrawlURIDispositionEvent(this, curi, SUCCEEDED));
|
||||
} else {
|
||||
eventPublisher.publishEvent(new CrawlURIDispositionEvent(this, curi, FAILED));
|
||||
}
|
||||
frontier.endDisposition();
|
||||
} catch (Exception e) {
|
||||
logger.log(WARNING, "Exception handling subrequest " + request.getUrl(), e);
|
||||
} finally {
|
||||
recorder.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void start() {
|
||||
if (isRunning) return;
|
||||
super.start();
|
||||
openWindowsSemaphore = new Semaphore(maxOpenWindows);
|
||||
|
||||
// If we're enabled by default launch the browser now to get early feedback if there's a connection error.
|
||||
// Otherwise, we launch it on demand so that we don't create browser processes for jobs that don't need it.
|
||||
if (getEnabled()) {
|
||||
ensureConnected();
|
||||
}
|
||||
|
||||
if (extractorChain == null) {
|
||||
// The fetch chain normally includes some preprocessing, fetch and extractor processors, but we want just
|
||||
// the extractors as we let the browser fetch subresources. So we construct a new chain consisting of the
|
||||
// extractors only.
|
||||
List<Processor> extractors = new ArrayList<>();
|
||||
for (Processor processor : controller.getFetchChain().getProcessors()) {
|
||||
if (processor instanceof Extractor) {
|
||||
extractors.add(processor);
|
||||
}
|
||||
}
|
||||
extractorChain = new ProcessorChain();
|
||||
extractorChain.setProcessors(extractors);
|
||||
}
|
||||
}
|
||||
|
||||
private synchronized void ensureConnected() {
|
||||
if (client != null) return;
|
||||
if (devtoolsUrl != null) {
|
||||
client = new ChromeClient(devtoolsUrl);
|
||||
} else {
|
||||
try {
|
||||
process = new ChromeProcess(executable, commandLineOptions);
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException("Failed to launch browser process", e);
|
||||
}
|
||||
client = new ChromeClient(process.getDevtoolsUrl());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void stop() {
|
||||
super.stop();
|
||||
if (client != null) {
|
||||
client.close();
|
||||
client = null;
|
||||
}
|
||||
if (process != null) {
|
||||
process.close();
|
||||
process = null;
|
||||
}
|
||||
}
|
||||
|
||||
public String getExecutable() {
|
||||
return executable;
|
||||
}
|
||||
|
||||
public void setExecutable(String executable) {
|
||||
this.executable = executable;
|
||||
}
|
||||
|
||||
public int getMaxOpenWindows() {
|
||||
return maxOpenWindows;
|
||||
}
|
||||
|
||||
public void setMaxOpenWindows(int maxOpenWindows) {
|
||||
this.maxOpenWindows = maxOpenWindows;
|
||||
}
|
||||
|
||||
public String getDevtoolsUrl() {
|
||||
return devtoolsUrl;
|
||||
}
|
||||
|
||||
public void setDevtoolsUrl(String devtoolsUrl) {
|
||||
this.devtoolsUrl = devtoolsUrl;
|
||||
}
|
||||
|
||||
public int getWindowWidth() {
|
||||
return windowWidth;
|
||||
}
|
||||
|
||||
public void setWindowWidth(int windowWidth) {
|
||||
this.windowWidth = windowWidth;
|
||||
}
|
||||
|
||||
public int getWindowHeight() {
|
||||
return windowHeight;
|
||||
}
|
||||
|
||||
public void setWindowHeight(int windowHeight) {
|
||||
this.windowHeight = windowHeight;
|
||||
}
|
||||
|
||||
public int getLoadTimeoutSeconds() {
|
||||
return loadTimeoutSeconds;
|
||||
}
|
||||
|
||||
public void setLoadTimeoutSeconds(int loadTimeoutSeconds) {
|
||||
this.loadTimeoutSeconds = loadTimeoutSeconds;
|
||||
}
|
||||
|
||||
public List<String> getCommandLineOptions() {
|
||||
return commandLineOptions;
|
||||
}
|
||||
|
||||
public void setCommandLineOptions(List<String> commandLineOptions) {
|
||||
this.commandLineOptions = commandLineOptions;
|
||||
}
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
/*
|
||||
* This file is part of the Heritrix web crawler (crawler.archive.org).
|
||||
*
|
||||
* Licensed to the Internet Archive (IA) by one or more individual
|
||||
* contributors.
|
||||
*
|
||||
* The IA licenses this file to You under the Apache License, Version 2.0
|
||||
* (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.archive.net.chrome;
|
||||
|
||||
import org.java_websocket.client.WebSocketClient;
|
||||
import org.java_websocket.handshake.ServerHandshake;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.io.Closeable;
|
||||
import java.net.URI;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.*;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.logging.Level;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
import static java.util.logging.Level.WARNING;
|
||||
|
||||
/**
|
||||
* A client for the <a href="https://chromedevtools.github.io/devtools-protocol/">Chrome Devtools Protocol</a>.
|
||||
*/
|
||||
public class ChromeClient implements Closeable {
|
||||
private static final Logger logger = Logger.getLogger(ChromeClient.class.getName());
|
||||
private static final int RPC_TIMEOUT_SECONDS = 60;
|
||||
|
||||
private final DevtoolsSocket devtoolsSocket;
|
||||
private final AtomicLong nextMessageId = new AtomicLong(0);
|
||||
private final Map<Long, CompletableFuture<JSONObject>> responseFutures = new ConcurrentHashMap<>();
|
||||
final ConcurrentHashMap<String, Consumer<JSONObject>> sessionEventHandlers = new ConcurrentHashMap<>();
|
||||
|
||||
public ChromeClient(String devtoolsUrl) {
|
||||
devtoolsSocket = new DevtoolsSocket(URI.create(devtoolsUrl));
|
||||
try {
|
||||
devtoolsSocket.connectBlocking();
|
||||
} catch (InterruptedException e) {
|
||||
throw new ChromeException("Interrupted while connecting", e);
|
||||
}
|
||||
}
|
||||
|
||||
public JSONObject call(String method, Object... keysAndValues) {
|
||||
return callInSession(null, method, keysAndValues);
|
||||
}
|
||||
|
||||
public JSONObject callInSession(String sessionId, String method, Object... keysAndValues) {
|
||||
JSONObject params = new JSONObject();
|
||||
if (keysAndValues.length % 2 != 0) {
|
||||
throw new IllegalArgumentException("keysAndValues.length must even");
|
||||
}
|
||||
for (int i = 0; i < keysAndValues.length; i += 2) {
|
||||
params.put((String)keysAndValues[i], keysAndValues[i + 1]);
|
||||
}
|
||||
return callInternal(sessionId, method, params);
|
||||
}
|
||||
|
||||
private JSONObject callInternal(String sessionId, String method, JSONObject params) {
|
||||
long id = nextMessageId.getAndIncrement();
|
||||
JSONObject message = new JSONObject();
|
||||
message.put("id", id);
|
||||
if (sessionId != null) {
|
||||
message.put("sessionId", sessionId);
|
||||
}
|
||||
message.put("method", method);
|
||||
message.put("params", params);
|
||||
CompletableFuture<JSONObject> future = new CompletableFuture<>();
|
||||
responseFutures.put(id, future);
|
||||
devtoolsSocket.send(message.toString());
|
||||
try {
|
||||
return future.get(RPC_TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
||||
} catch (InterruptedException e) {
|
||||
throw new ChromeException("Call interrupted", e);
|
||||
} catch (TimeoutException e) {
|
||||
throw new ChromeException("Call timed out: " + message, e);
|
||||
} catch (ExecutionException e) {
|
||||
throw new ChromeException("Call failed: " + message + ": " + e.getMessage(), e.getCause());
|
||||
}
|
||||
}
|
||||
|
||||
private void handleResponse(JSONObject message) {
|
||||
long id = message.getLong("id");
|
||||
CompletableFuture<JSONObject> future = responseFutures.remove(id);
|
||||
if (future == null) {
|
||||
logger.log(WARNING, "Unexpected RPC response id {0}", id);
|
||||
} else if (message.has("error")) {
|
||||
future.completeExceptionally(new ChromeException(message.getJSONObject("error").getString("message")));
|
||||
} else {
|
||||
future.complete(message.getJSONObject("result"));
|
||||
}
|
||||
}
|
||||
|
||||
private void handleEvent(JSONObject message) {
|
||||
if (message.has("sessionId")) {
|
||||
String sessionId = message.getString("sessionId");
|
||||
Consumer<JSONObject> handler = sessionEventHandlers.get(sessionId);
|
||||
if (handler != null) {
|
||||
handler.accept(message);
|
||||
} else {
|
||||
logger.log(WARNING, "Received event for unknown session {0}", sessionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public ChromeWindow createWindow(int width, int height) {
|
||||
String targetId = call("Target.createTarget", "url", "about:blank",
|
||||
"width", width, "height", height).getString("targetId");
|
||||
return new ChromeWindow(this, targetId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
devtoolsSocket.close();
|
||||
}
|
||||
|
||||
private class DevtoolsSocket extends WebSocketClient {
|
||||
public DevtoolsSocket(URI uri) {
|
||||
super(uri);
|
||||
setConnectionLostTimeout(-1); // disable pings - Chromium doesn't support them
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onOpen(ServerHandshake serverHandshake) {
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onMessage(String messageString) {
|
||||
try {
|
||||
JSONObject message = new JSONObject(messageString);
|
||||
if (message.has("method")) {
|
||||
handleEvent(message);
|
||||
} else {
|
||||
handleResponse(message);
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
logger.log(WARNING, "Exception handling message from Chromium", e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onClose(int code, String reason, boolean remote) {
|
||||
if (!remote) return;
|
||||
logger.log(WARNING, "Websocket closed by browser: " + reason);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(Exception e) {
|
||||
logger.log(Level.SEVERE, "Websocket error", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
/*
|
||||
* This file is part of the Heritrix web crawler (crawler.archive.org).
|
||||
*
|
||||
* Licensed to the Internet Archive (IA) by one or more individual
|
||||
* contributors.
|
||||
*
|
||||
* The IA licenses this file to You under the Apache License, Version 2.0
|
||||
* (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.archive.net.chrome;
|
||||
|
||||
public class ChromeException extends RuntimeException {
|
||||
public ChromeException() {
|
||||
super();
|
||||
}
|
||||
|
||||
public ChromeException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public ChromeException(String message, Throwable throwable) {
|
||||
super(message, throwable);
|
||||
}
|
||||
|
||||
public ChromeException(Throwable throwable) {
|
||||
super(throwable);
|
||||
}
|
||||
}
|
||||
@@ -1,197 +0,0 @@
|
||||
/*
|
||||
* This file is part of the Heritrix web crawler (crawler.archive.org).
|
||||
*
|
||||
* Licensed to the Internet Archive (IA) by one or more individual
|
||||
* contributors.
|
||||
*
|
||||
* The IA licenses this file to You under the Apache License, Version 2.0
|
||||
* (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.archive.net.chrome;
|
||||
|
||||
import org.archive.modules.extractor.ExtractorChrome;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.Closeable;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStreamReader;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
import static java.nio.charset.StandardCharsets.ISO_8859_1;
|
||||
import static java.util.logging.Level.FINER;
|
||||
|
||||
/**
|
||||
* Manages starting and stopping a browser process.
|
||||
*/
|
||||
public class ChromeProcess implements Closeable {
|
||||
private static final Logger logger = Logger.getLogger(ExtractorChrome.class.getName());
|
||||
|
||||
private static final String[] DEFAULT_EXECUTABLES = {"chromium-browser", "chromium", "google-chrome",
|
||||
"C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
|
||||
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
||||
"firefox"};
|
||||
private static final int SHUTDOWN_TIMEOUT_SECONDS = 2;
|
||||
|
||||
private static final Set<Process> runningProcesses = Collections.newSetFromMap(new ConcurrentHashMap<>());
|
||||
private static Thread shutdownHook;
|
||||
|
||||
private final Process process;
|
||||
private final String devtoolsUrl;
|
||||
|
||||
public ChromeProcess(String executable, List<String> commandLineOptions) throws IOException {
|
||||
process = executable == null ? launchAny(commandLineOptions) : launch(executable, commandLineOptions);
|
||||
runningProcesses.add(process);
|
||||
registerShutdownHook();
|
||||
devtoolsUrl = readDevtoolsUriFromStderr(process);
|
||||
}
|
||||
|
||||
private static Process launch(String executable, List<String> commandLineOptions) throws IOException {
|
||||
List<String> command = new ArrayList<>();
|
||||
command.add(executable);
|
||||
command.add("--headless");
|
||||
command.add("--remote-debugging-port=0");
|
||||
|
||||
// https://github.com/GoogleChrome/chrome-launcher/blob/master/docs/chrome-flags-for-tools.md
|
||||
command.add("--disable-background-networking");
|
||||
command.add("--disable-background-timer-throttling");
|
||||
command.add("--disable-backgrounding-occluded-windows");
|
||||
command.add("--disable-breakpad");
|
||||
command.add("--disable-client-side-phishing-detection");
|
||||
command.add("--disable-component-extensions-with-background-pages");
|
||||
command.add("--disable-component-update");
|
||||
command.add("--disable-crash-reporter");
|
||||
command.add("--disable-default-apps");
|
||||
command.add("--disable-extensions");
|
||||
command.add("--disable-features=Translate");
|
||||
command.add("--disable-ipc-flooding-protection");
|
||||
command.add("--disable-popup-blocking");
|
||||
command.add("--disable-prompt-on-repost");
|
||||
command.add("--disable-renderer-backgrounding");
|
||||
command.add("--disable-sync");
|
||||
command.add("--metrics-recording-only");
|
||||
command.add("--mute-audio");
|
||||
command.add("--no-default-browser-check");
|
||||
command.add("--no-first-run");
|
||||
command.add("--password-store=basic");
|
||||
command.add("--use-mock-keychain");
|
||||
|
||||
command.addAll(commandLineOptions);
|
||||
return new ProcessBuilder(command)
|
||||
.inheritIO()
|
||||
.redirectError(ProcessBuilder.Redirect.PIPE)
|
||||
.start();
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to launch the browser process using each of DEFAUSLT_EXECUTABLES in turn until one succeeds.
|
||||
*/
|
||||
private static Process launchAny(List<String> extraCommandLineOptions) throws IOException {
|
||||
IOException lastException = null;
|
||||
for (String executable : DEFAULT_EXECUTABLES) {
|
||||
try {
|
||||
return launch(executable, extraCommandLineOptions);
|
||||
} catch (IOException e) {
|
||||
lastException = e;
|
||||
}
|
||||
}
|
||||
throw new IOException("Failed to launch any of " + Arrays.asList(DEFAULT_EXECUTABLES), lastException);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
destroyProcess(process);
|
||||
runningProcesses.remove(process);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a shutdown hook that destroys all running browser processes before exiting in case stop() is never
|
||||
* called. This can happen if the Heritrix exits abnormally.
|
||||
*/
|
||||
private static synchronized void registerShutdownHook() {
|
||||
if (shutdownHook != null) return;
|
||||
shutdownHook = new Thread(ChromeProcess::destroyAllRunningProcesses, "ChromiumClient shutdown hook");
|
||||
Runtime.getRuntime().addShutdownHook(shutdownHook);
|
||||
}
|
||||
|
||||
private static void destroyAllRunningProcesses() {
|
||||
for (Process process : runningProcesses) {
|
||||
process.destroy();
|
||||
}
|
||||
for (Process process : runningProcesses) {
|
||||
try {
|
||||
if (!process.waitFor(SHUTDOWN_TIMEOUT_SECONDS, TimeUnit.SECONDS)) {
|
||||
break;
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
for (Process process : runningProcesses) {
|
||||
process.destroyForcibly();
|
||||
}
|
||||
}
|
||||
|
||||
private static void destroyProcess(Process process) {
|
||||
process.destroy();
|
||||
try {
|
||||
process.waitFor(SHUTDOWN_TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
} finally {
|
||||
process.destroyForcibly();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the stderr of a Chromium process and returns the DevTools URI. Once this method
|
||||
* returns stderr will continue to be consumed and logged by a background thread.
|
||||
*/
|
||||
private static String readDevtoolsUriFromStderr(Process process) throws IOException {
|
||||
BufferedReader stderr = new BufferedReader(new InputStreamReader(process.getErrorStream(), ISO_8859_1));
|
||||
CompletableFuture<String> future = new CompletableFuture<>();
|
||||
Thread thread = new Thread(() -> {
|
||||
String listenMsg = "DevTools listening on ";
|
||||
try {
|
||||
while (true) {
|
||||
String line = stderr.readLine();
|
||||
if (line == null) break;
|
||||
if (!future.isDone() && line.startsWith(listenMsg)) {
|
||||
future.complete(line.substring(listenMsg.length()));
|
||||
}
|
||||
logger.log(FINER, "Chromium STDERR: {0}", line);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
future.completeExceptionally(e);
|
||||
}
|
||||
});
|
||||
thread.setName("Chromium stderr reader");
|
||||
thread.setDaemon(true);
|
||||
thread.start();
|
||||
|
||||
try {
|
||||
return future.get(10, TimeUnit.SECONDS);
|
||||
} catch (InterruptedException | ExecutionException | TimeoutException e) {
|
||||
// unwrap the exception if we can to cut down on log noise
|
||||
if (e.getCause() instanceof IOException) {
|
||||
throw (IOException) e.getCause();
|
||||
}
|
||||
throw new IOException(e);
|
||||
}
|
||||
}
|
||||
|
||||
public String getDevtoolsUrl() {
|
||||
return devtoolsUrl;
|
||||
}
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
/*
|
||||
* This file is part of the Heritrix web crawler (crawler.archive.org).
|
||||
*
|
||||
* Licensed to the Internet Archive (IA) by one or more individual
|
||||
* contributors.
|
||||
*
|
||||
* The IA licenses this file to You under the Apache License, Version 2.0
|
||||
* (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.archive.net.chrome;
|
||||
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Base64;
|
||||
|
||||
public class ChromeRequest {
|
||||
private final ChromeWindow window;
|
||||
private final String id;
|
||||
private JSONObject requestJson;
|
||||
private JSONObject rawRequestHeaders;
|
||||
private JSONObject responseJson;
|
||||
private JSONObject rawResponseHeaders;
|
||||
private String responseHeadersText;
|
||||
private final long beginTime = System.currentTimeMillis();
|
||||
private boolean responseFulfilledByInterception;
|
||||
|
||||
public ChromeRequest(ChromeWindow window, String id) {
|
||||
this.window = window;
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
void setRawRequestHeaders(JSONObject rawRequestHeaders) {
|
||||
this.rawRequestHeaders = rawRequestHeaders;
|
||||
}
|
||||
|
||||
void setResponseJson(JSONObject responseJson) {
|
||||
this.responseJson = responseJson;
|
||||
}
|
||||
|
||||
void setRawResponseHeaders(JSONObject headers) {
|
||||
this.rawResponseHeaders = headers;
|
||||
}
|
||||
|
||||
void setResponseHeadersText(String responseHeadersText) {
|
||||
this.responseHeadersText = responseHeadersText;
|
||||
}
|
||||
|
||||
public String getUrl() {
|
||||
return requestJson.getString("url");
|
||||
}
|
||||
|
||||
public byte[] getResponseBody() {
|
||||
JSONObject reply = window.call("Network.getResponseBody", "requestId", id);
|
||||
byte[] body;
|
||||
if (reply.getBoolean("base64Encoded")) {
|
||||
body = Base64.getDecoder().decode(reply.getString("body"));
|
||||
} else {
|
||||
body = reply.getString("body").getBytes(StandardCharsets.UTF_8);
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
public String getRequestHeader() {
|
||||
if (responseJson != null && responseJson.has("requestHeadersText")) {
|
||||
return responseJson.getString("requestHeadersText");
|
||||
}
|
||||
StringBuilder builder = new StringBuilder();
|
||||
builder.append(requestJson.getString("method"));
|
||||
builder.append(' ');
|
||||
builder.append(getUrl());
|
||||
builder.append(" HTTP/1.1\r\n");
|
||||
formatHeaders(builder, requestJson.getJSONObject("headers"), rawRequestHeaders);
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
public byte[] getRequestBody() {
|
||||
if (requestJson.has("postData")) {
|
||||
return requestJson.getString("postData").getBytes(StandardCharsets.UTF_8);
|
||||
} else {
|
||||
return new byte[0];
|
||||
}
|
||||
}
|
||||
|
||||
public String getResponseHeader() {
|
||||
if (responseHeadersText != null) {
|
||||
return responseHeadersText;
|
||||
} else if (responseJson.has("headersText")) {
|
||||
return responseJson.getString("headersText");
|
||||
}
|
||||
StringBuilder builder = new StringBuilder();
|
||||
if (responseJson.getString("protocol").equals("http/1.0")) {
|
||||
builder.append("HTTP/1.0");
|
||||
} else {
|
||||
builder.append("HTTP/1.1");
|
||||
}
|
||||
builder.append(getStatus());
|
||||
builder.append(" ");
|
||||
builder.append(responseJson.getString("statusText"));
|
||||
builder.append("\r\n");
|
||||
formatHeaders(builder, responseJson.getJSONObject("headers"), rawResponseHeaders);
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
private void formatHeaders(StringBuilder builder, JSONObject headers, JSONObject rawHeaders) {
|
||||
if (rawHeaders != null) {
|
||||
headers = rawHeaders;
|
||||
}
|
||||
for (Object key : headers.keySet()) {
|
||||
builder.append(key);
|
||||
builder.append(": ");
|
||||
builder.append(headers.getString((String) key));
|
||||
builder.append("\r\n");
|
||||
}
|
||||
builder.append("\r\n");
|
||||
}
|
||||
|
||||
public String getMethod() {
|
||||
return requestJson.getString("method");
|
||||
}
|
||||
|
||||
public int getStatus() {
|
||||
return responseJson.getInt("status");
|
||||
}
|
||||
|
||||
public String getResponseContentType() {
|
||||
return responseJson.getString("mimeType");
|
||||
}
|
||||
|
||||
public long getBeginTime() {
|
||||
return beginTime;
|
||||
}
|
||||
|
||||
public String getRemoteIPAddress() {
|
||||
if (responseJson.has("remoteIPAddress")) {
|
||||
return responseJson.getString("remoteIPAddress");
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
void setRequestJson(JSONObject requestJson) {
|
||||
this.requestJson = requestJson;
|
||||
}
|
||||
|
||||
void setResponseFulfilledByInterception(boolean responseFulfilledByInterception) {
|
||||
this.responseFulfilledByInterception = responseFulfilledByInterception;
|
||||
}
|
||||
|
||||
public boolean isResponseFulfilledByInterception() {
|
||||
return responseFulfilledByInterception;
|
||||
}
|
||||
}
|
||||
@@ -1,233 +0,0 @@
|
||||
/*
|
||||
* This file is part of the Heritrix web crawler (crawler.archive.org).
|
||||
*
|
||||
* Licensed to the Internet Archive (IA) by one or more individual
|
||||
* contributors.
|
||||
*
|
||||
* The IA licenses this file to You under the Apache License, Version 2.0
|
||||
* (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.archive.net.chrome;
|
||||
|
||||
import org.java_websocket.exceptions.WebsocketNotConnectedException;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.io.Closeable;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.*;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
import static java.util.logging.Level.*;
|
||||
|
||||
/**
|
||||
* A browser window or tab.
|
||||
*/
|
||||
public class ChromeWindow implements Closeable {
|
||||
private static final Logger logger = Logger.getLogger(ChromeWindow.class.getName());
|
||||
|
||||
private final ChromeClient client;
|
||||
private final String targetId;
|
||||
private final String sessionId;
|
||||
private boolean closed;
|
||||
private CompletableFuture<Void> loadEventFuture;
|
||||
private final Map<String,ChromeRequest> requestMap = new ConcurrentHashMap<>();
|
||||
private Consumer<ChromeRequest> requestConsumer;
|
||||
private Consumer<InterceptedRequest> requestInterceptor;
|
||||
private final ExecutorService eventExecutor;
|
||||
|
||||
public ChromeWindow(ChromeClient client, String targetId) {
|
||||
this.client = client;
|
||||
this.targetId = targetId;
|
||||
this.sessionId = client.call("Target.attachToTarget", "targetId", targetId,
|
||||
"flatten", true).getString("sessionId");
|
||||
eventExecutor = Executors.newSingleThreadExecutor(runnable ->
|
||||
new Thread(runnable, "ChromeWindow (sessionId=" + sessionId +")"));
|
||||
client.sessionEventHandlers.put(sessionId, this::handleEvent);
|
||||
call("Page.enable"); // for loadEventFired
|
||||
call("Page.setLifecycleEventsEnabled", "enabled", true); // for networkidle
|
||||
call("Runtime.enable"); // required by Firefox for Runtime.evaluate to work
|
||||
}
|
||||
|
||||
/**
|
||||
* Call a devtools method in the session of this window.
|
||||
*/
|
||||
public JSONObject call(String method, Object... keysAndValues) {
|
||||
return client.callInSession(sessionId, method, keysAndValues);
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate a JavaScript expression.
|
||||
*/
|
||||
public JSONObject eval(String expression) {
|
||||
return call("Runtime.evaluate", "expression", expression,
|
||||
"returnByValue", true).getJSONObject("result");
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate this window to a new URL. Returns a future which will be fulfilled when the page finishes loading.
|
||||
*/
|
||||
public CompletableFuture<Void> navigateAsync(String url) {
|
||||
if (loadEventFuture != null) {
|
||||
loadEventFuture.cancel(false);
|
||||
}
|
||||
loadEventFuture = new CompletableFuture<>();
|
||||
call("Page.navigate", "url", url);
|
||||
return loadEventFuture;
|
||||
}
|
||||
|
||||
private void handleEvent(JSONObject message) {
|
||||
if (closed) return;
|
||||
// Run event handlers on a different thread so we don't block the websocket receiving thread.
|
||||
// That would cause a deadlock if an event handler itself made an RPC call as the response could
|
||||
// never be processed.
|
||||
// We use a single thread per window though as the order events are processed is important.
|
||||
eventExecutor.submit(() -> {
|
||||
try {
|
||||
handleEventOnEventThread(message);
|
||||
} catch (Throwable t) {
|
||||
logger.log(WARNING, "Exception handling browser event " + message, t);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void handleEventOnEventThread(JSONObject message) {
|
||||
JSONObject params = message.getJSONObject("params");
|
||||
switch (message.getString("method")) {
|
||||
case "Fetch.requestPaused":
|
||||
handlePausedRequest(params);
|
||||
break;
|
||||
case "Network.requestWillBeSent":
|
||||
handleRequestWillBeSent(params);
|
||||
break;
|
||||
case "Network.requestWillBeSentExtraInfo":
|
||||
handleRequestWillBeSentExtraInfo(params);
|
||||
break;
|
||||
case "Network.responseReceived":
|
||||
handleResponseReceived(params);
|
||||
break;
|
||||
case "Network.responseReceivedExtraInfo":
|
||||
handleResponseReceivedExtraInfo(params);
|
||||
break;
|
||||
case "Network.loadingFinished":
|
||||
handleLoadingFinished(params);
|
||||
break;
|
||||
case "Page.loadEventFired":
|
||||
if (loadEventFuture != null) {
|
||||
try {
|
||||
Thread.sleep(1000);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
loadEventFuture.complete(null);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
logger.log(FINE, "Unhandled event {0}", message);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void handlePausedRequest(JSONObject params) {
|
||||
String networkId = params.getString("networkId");
|
||||
ChromeRequest request = requestMap.computeIfAbsent(networkId, id -> new ChromeRequest(this, id));
|
||||
request.setRequestJson(params.getJSONObject("request"));
|
||||
String id = params.getString("requestId");
|
||||
InterceptedRequest interceptedRequest = new InterceptedRequest(this, id, request);
|
||||
try {
|
||||
requestInterceptor.accept(interceptedRequest);
|
||||
} catch (Exception e) {
|
||||
logger.log(SEVERE, "Request interceptor threw", e);
|
||||
}
|
||||
if (!interceptedRequest.isHandled()) {
|
||||
interceptedRequest.continueNormally();
|
||||
}
|
||||
}
|
||||
|
||||
private void handleRequestWillBeSent(JSONObject params) {
|
||||
String requestId = params.getString("requestId");
|
||||
ChromeRequest request = requestMap.computeIfAbsent(requestId, id -> new ChromeRequest(this, id));
|
||||
request.setRequestJson(params.getJSONObject("request"));
|
||||
}
|
||||
|
||||
private void handleRequestWillBeSentExtraInfo(JSONObject params) {
|
||||
// it seems this event can arrive both before and after requestWillBeSent so we need to cope with that
|
||||
String requestId = params.getString("requestId");
|
||||
ChromeRequest request = requestMap.computeIfAbsent(requestId, id -> new ChromeRequest(this, id));
|
||||
if (params.has("headers")) {
|
||||
request.setRawRequestHeaders(params.getJSONObject("headers"));
|
||||
}
|
||||
}
|
||||
|
||||
private void handleResponseReceived(JSONObject params) {
|
||||
ChromeRequest request = requestMap.get(params.getString("requestId"));
|
||||
if (request == null) {
|
||||
logger.log(WARNING, "Got responseReceived event without corresponding requestWillBeSent");
|
||||
return;
|
||||
}
|
||||
request.setResponseJson(params.getJSONObject("response"));
|
||||
}
|
||||
|
||||
private void handleResponseReceivedExtraInfo(JSONObject params) {
|
||||
ChromeRequest request = requestMap.get(params.getString("requestId"));
|
||||
if (request == null) {
|
||||
logger.log(WARNING, "Got responseReceivedExtraInfo event without corresponding requestWillBeSent");
|
||||
return;
|
||||
}
|
||||
if (params.has("headers")) {
|
||||
request.setRawResponseHeaders(params.getJSONObject("headers"));
|
||||
}
|
||||
if (params.has("headersText")) {
|
||||
request.setResponseHeadersText(params.getString("headersText"));
|
||||
}
|
||||
}
|
||||
|
||||
private void handleLoadingFinished(JSONObject params) {
|
||||
ChromeRequest request = requestMap.get(params.getString("requestId"));
|
||||
if (request == null) {
|
||||
logger.log(WARNING, "Got loadingFinished event without corresponding requestWillBeSent");
|
||||
return;
|
||||
}
|
||||
if (requestConsumer != null) {
|
||||
requestConsumer.accept(request);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
eventExecutor.shutdown();
|
||||
try {
|
||||
eventExecutor.awaitTermination(1, TimeUnit.SECONDS);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
try {
|
||||
client.call("Target.closeTarget", "targetId", targetId);
|
||||
} catch (WebsocketNotConnectedException e) {
|
||||
// no need to close the window if the browser has already exited
|
||||
}
|
||||
client.sessionEventHandlers.remove(sessionId);
|
||||
}
|
||||
|
||||
public void captureRequests(Consumer<ChromeRequest> requestConsumer) {
|
||||
this.requestConsumer = requestConsumer;
|
||||
call("Network.enable");
|
||||
}
|
||||
|
||||
public void interceptRequests(Consumer<InterceptedRequest> requestInterceptor) {
|
||||
this.requestInterceptor = requestInterceptor;
|
||||
call("Fetch.enable");
|
||||
}
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
/*
|
||||
* This file is part of the Heritrix web crawler (crawler.archive.org).
|
||||
*
|
||||
* Licensed to the Internet Archive (IA) by one or more individual
|
||||
* contributors.
|
||||
*
|
||||
* The IA licenses this file to You under the Apache License, Version 2.0
|
||||
* (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.archive.net.chrome;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.util.Base64;
|
||||
import java.util.Collection;
|
||||
import java.util.Map;
|
||||
|
||||
public class InterceptedRequest {
|
||||
private final String id;
|
||||
private final ChromeRequest request;
|
||||
private final ChromeWindow window;
|
||||
private boolean handled;
|
||||
|
||||
public InterceptedRequest(ChromeWindow window, String id, ChromeRequest request) {
|
||||
this.window = window;
|
||||
this.id = id;
|
||||
this.request = request;
|
||||
}
|
||||
|
||||
public ChromeRequest getRequest() {
|
||||
return request;
|
||||
}
|
||||
|
||||
public void fulfill(int status, Collection<Map.Entry<String,String>> headers, byte[] body) {
|
||||
setHandled();
|
||||
JSONArray headerArray = new JSONArray();
|
||||
for (Map.Entry<String,String> entry : headers) {
|
||||
JSONObject object = new JSONObject();
|
||||
object.put("name", entry.getKey());
|
||||
object.put("value", entry.getValue());
|
||||
headerArray.put(object);
|
||||
}
|
||||
String encodedBody = Base64.getEncoder().encodeToString(body);
|
||||
request.setResponseFulfilledByInterception(true);
|
||||
window.call("Fetch.fulfillRequest",
|
||||
"requestId", id,
|
||||
"responseCode", status,
|
||||
"responseHeaders", headerArray,
|
||||
"body", encodedBody);
|
||||
}
|
||||
|
||||
public void continueNormally() {
|
||||
setHandled();
|
||||
window.call("Fetch.continueRequest", "requestId", id);
|
||||
}
|
||||
|
||||
public boolean isHandled() {
|
||||
return handled;
|
||||
}
|
||||
|
||||
private void setHandled() {
|
||||
if (handled) {
|
||||
throw new IllegalStateException("intercepted request already handled");
|
||||
}
|
||||
handled = true;
|
||||
}
|
||||
}
|
||||
@@ -1,203 +0,0 @@
|
||||
/*
|
||||
* This file is part of the Heritrix web crawler (crawler.archive.org).
|
||||
*
|
||||
* Licensed to the Internet Archive (IA) by one or more individual
|
||||
* contributors.
|
||||
*
|
||||
* The IA licenses this file to You under the Apache License, Version 2.0
|
||||
* (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.archive.modules.extractor;
|
||||
|
||||
import org.archive.crawler.framework.CrawlController;
|
||||
import org.archive.crawler.framework.Frontier;
|
||||
import org.archive.crawler.reporting.CrawlerLoggerModule;
|
||||
import org.archive.modules.*;
|
||||
import org.archive.modules.fetcher.DefaultServerCache;
|
||||
import org.archive.modules.fetcher.FetchHTTP;
|
||||
import org.archive.modules.fetcher.SimpleCookieStore;
|
||||
import org.archive.net.UURIFactory;
|
||||
import org.archive.util.Recorder;
|
||||
import org.eclipse.jetty.server.Request;
|
||||
import org.eclipse.jetty.server.Server;
|
||||
import org.eclipse.jetty.server.handler.AbstractHandler;
|
||||
import org.junit.AfterClass;
|
||||
import org.junit.BeforeClass;
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.rules.TemporaryFolder;
|
||||
|
||||
import javax.servlet.ServletException;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import java.io.IOException;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.util.*;
|
||||
import java.util.logging.Level;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
import static java.util.stream.Collectors.toList;
|
||||
import static org.archive.modules.CrawlURI.FetchType.HTTP_GET;
|
||||
import static org.archive.modules.CrawlURI.FetchType.HTTP_POST;
|
||||
import static org.easymock.EasyMock.*;
|
||||
import static org.junit.Assert.*;
|
||||
import static org.junit.Assume.assumeNoException;
|
||||
|
||||
public class ExtractorChromeTest {
|
||||
private static Server server;
|
||||
|
||||
@BeforeClass
|
||||
public static void startServer() throws Exception {
|
||||
server = new Server(InetSocketAddress.createUnresolved("127.0.0.1", 7778));
|
||||
server.setHandler(new AbstractHandler() {
|
||||
@Override
|
||||
public void handle(String target, Request baseRequest, HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException {
|
||||
switch (target) {
|
||||
case "/":
|
||||
response.setContentType("text/html");
|
||||
response.getWriter().write("<a href=http://example.org/page2.html>link</a>" +
|
||||
"<img src=/blue.png>" +
|
||||
"<img src='data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=='>" +
|
||||
"<script>fetch('/post', {method: 'POST', body: 'hello'});</script>" +
|
||||
"<link rel=stylesheet href=style.css>");
|
||||
baseRequest.setHandled(true);
|
||||
break;
|
||||
case "/style.css":
|
||||
response.setContentType("text/css");
|
||||
response.getWriter().write("@media only print { body { background: url('printonly.png'); } }");
|
||||
baseRequest.setHandled(true);
|
||||
break;
|
||||
case "/blue.png":
|
||||
case "/printonly.png":
|
||||
response.setContentType("image/png");
|
||||
response.getWriter().write("bogus png");
|
||||
baseRequest.setHandled(true);
|
||||
break;
|
||||
case "/post":
|
||||
response.setContentType("plain/text");
|
||||
response.getWriter().write("method=" + request.getMethod());
|
||||
baseRequest.setHandled(true);
|
||||
break;
|
||||
default:
|
||||
System.err.println("Unhandled target: " + target);
|
||||
break;
|
||||
}
|
||||
|
||||
}
|
||||
});
|
||||
server.start();
|
||||
}
|
||||
|
||||
@AfterClass
|
||||
public static void stopServer() throws Exception {
|
||||
server.stop();
|
||||
}
|
||||
|
||||
@Rule
|
||||
public TemporaryFolder tempFolder = new TemporaryFolder();
|
||||
|
||||
@Test
|
||||
public void test() throws IOException, InterruptedException {
|
||||
List<CrawlURI> processedURIs = Collections.synchronizedList(new ArrayList<>());
|
||||
|
||||
DispositionChain dispositionChain = new DispositionChain();
|
||||
dispositionChain.setProcessors(Arrays.asList(new Processor() {
|
||||
@Override
|
||||
protected boolean shouldProcess(CrawlURI uri) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void innerProcess(CrawlURI uri) {
|
||||
processedURIs.add(uri);
|
||||
}
|
||||
}));
|
||||
|
||||
FetchChain fetchChain = new FetchChain();
|
||||
fetchChain.setProcessors(Arrays.asList(new ExtractorCSS()));
|
||||
|
||||
CrawlController controller = new CrawlController();
|
||||
controller.setDispositionChain(dispositionChain);
|
||||
controller.setFetchChain(fetchChain);
|
||||
controller.setLoggerModule(new CrawlerLoggerModule() {
|
||||
@Override
|
||||
public Logger getUriProcessing() {
|
||||
Logger logger = Logger.getAnonymousLogger();
|
||||
logger.setLevel(Level.WARNING);
|
||||
return logger;
|
||||
}
|
||||
});
|
||||
Frontier frontier = createMock(Frontier.class);
|
||||
frontier.considerIncluded(anyObject());
|
||||
expectLastCall().anyTimes();
|
||||
frontier.beginDisposition(anyObject());
|
||||
expectLastCall().anyTimes();
|
||||
frontier.endDisposition();
|
||||
expectLastCall().anyTimes();
|
||||
frontier.finished(anyObject());
|
||||
expectLastCall().anyTimes();
|
||||
replay(frontier);
|
||||
controller.setFrontier(frontier);
|
||||
|
||||
FetchHTTP fetchHTTP = new FetchHTTP();
|
||||
fetchHTTP.setServerCache(new DefaultServerCache());
|
||||
fetchHTTP.setCookieStore(new SimpleCookieStore());
|
||||
fetchHTTP.setUserAgentProvider(new CrawlMetadata());
|
||||
fetchHTTP.start();
|
||||
|
||||
ExtractorChrome extractor = new ExtractorChrome(controller, event -> { /* ignored */ });
|
||||
try {
|
||||
extractor.start();
|
||||
} catch (RuntimeException e) {
|
||||
assumeNoException("Unable to start Chrome", e);
|
||||
}
|
||||
|
||||
Recorder recorder = new Recorder(tempFolder.newFile(), 1024, 1024);
|
||||
try {
|
||||
CrawlURI curi = new CrawlURI(UURIFactory.getInstance("http://127.0.0.1:7778/"));
|
||||
|
||||
Recorder.setHttpRecorder(recorder);
|
||||
curi.setRecorder(recorder);
|
||||
fetchHTTP.process(curi);
|
||||
|
||||
extractor.innerExtract(curi);
|
||||
|
||||
List<String> outLinks = curi.getOutLinks().stream().map(CrawlURI::toString).sorted().collect(toList());
|
||||
assertEquals(Collections.singletonList("http://example.org/page2.html"), outLinks);
|
||||
} finally {
|
||||
extractor.stop();
|
||||
fetchHTTP.stop();
|
||||
recorder.cleanup();
|
||||
}
|
||||
|
||||
assertEquals(3, processedURIs.size());
|
||||
Set<String> subresourceUrls = new HashSet<>();
|
||||
for (CrawlURI curi: processedURIs) {
|
||||
assertEquals(200, curi.getFetchStatus());
|
||||
assertEquals(curi.getUURI().getPath().equals("/post") ? HTTP_POST : HTTP_GET, curi.getFetchType());
|
||||
assertNotNull(curi.getContentDigest());
|
||||
assertTrue(curi.getContentSize() > 0);
|
||||
subresourceUrls.add(curi.getURI());
|
||||
|
||||
if (curi.getURI().equals("http://127.0.0.1:7778/style.css")) {
|
||||
assertEquals("check link extraction ran on captured resources",
|
||||
"http://127.0.0.1:7778/printonly.png",
|
||||
new ArrayList<>(curi.getOutLinks()).get(0).getURI());
|
||||
}
|
||||
}
|
||||
assertEquals(new HashSet<>(Arrays.asList(
|
||||
"http://127.0.0.1:7778/style.css",
|
||||
"http://127.0.0.1:7778/blue.png",
|
||||
"http://127.0.0.1:7778/post")), subresourceUrls);
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
/*
|
||||
* This file is part of the Heritrix web crawler (crawler.archive.org).
|
||||
*
|
||||
* Licensed to the Internet Archive (IA) by one or more individual
|
||||
* contributors.
|
||||
*
|
||||
* The IA licenses this file to You under the Apache License, Version 2.0
|
||||
* (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.archive.net.chrome;
|
||||
|
||||
import org.json.JSONObject;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Collections;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assume.assumeNoException;
|
||||
|
||||
public class ChromeClientTest {
|
||||
@Test
|
||||
public void navigate() throws Exception {
|
||||
ChromeProcess triedProcess;
|
||||
try {
|
||||
triedProcess = new ChromeProcess(null, Collections.emptyList());
|
||||
} catch (IOException e) {
|
||||
assumeNoException("Chrome unavailable", e);
|
||||
return;
|
||||
}
|
||||
try (ChromeProcess process = triedProcess;
|
||||
ChromeClient client = new ChromeClient(process.getDevtoolsUrl());
|
||||
ChromeWindow window = client.createWindow(1024, 768)) {
|
||||
window.navigateAsync("data:text/html,<h1>hi</h1>").get(10, TimeUnit.SECONDS);
|
||||
JSONObject result = window.eval("document.getElementsByTagName('h1')[0].textContent");
|
||||
assertEquals("hi", result.getString("value"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -378,11 +378,6 @@ FetchWhois
|
||||
Link Extractors
|
||||
---------------
|
||||
|
||||
ExtractorChrome (contrib)
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
.. bean-doc:: ../contrib/src/main/java/org/archive/modules/extractor/ExtractorChrome.java
|
||||
|
||||
ExtractorCSS
|
||||
~~~~~~~~~~~~
|
||||
|
||||
|
||||
Reference in New Issue
Block a user