├── .github └── workflows │ └── npm-publish.yml ├── .gitignore ├── Dockerfile ├── Procfile ├── api.js ├── app.json ├── area.js ├── index.html ├── index.js ├── lib └── puppeteer.js ├── package-lock.json ├── package.json ├── readme.md └── server.js /.github/workflows/npm-publish.yml: -------------------------------------------------------------------------------- 1 | name: publish npm package 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | 7 | jobs: 8 | publish: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v1 12 | - uses: actions/setup-node@v1 13 | with: 14 | node-version: 10 15 | - run: npm install 16 | - uses: JS-DevTools/npm-publish@v1 17 | with: 18 | token: ${{ secrets.NPM_TOKEN }} 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM thompsnm/nodejs-chrome 2 | COPY . development/ 3 | WORKDIR development 4 | RUN npm install 5 | ENTRYPOINT [ "node", "index.js" ] 6 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: node --inspect index.js 2 | -------------------------------------------------------------------------------- /api.js: -------------------------------------------------------------------------------- 1 | const cheerio = require('cheerio'); 2 | const moment = require('moment'); 3 | const request = require('request'); 4 | var brotli = require('brotli'); 5 | 6 | const debug = (...args) => { 7 | if (true) { 8 | console.log.apply(console, args); 9 | } 10 | } 11 | 12 | function parsePosition(position) { 13 | debug('Position: ', position); 14 | 15 | return { 16 | "error": position.error, 17 | "data": 18 | { 19 | timestamp: position.data.timestamp, 20 | unixtime: position.data.unixtime, 21 | latitude: parseFloat(position.data.latitude), 22 | longitude: parseFloat(position.data.longitude), 23 | course: parseFloat(position.data.course), 24 | speed: parseFloat(position.data.speed) 25 | } 26 | } 27 | } 28 | 29 | const headersVF = { 30 | 'User-Agent': 'user-agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/74.0.3703.0 Safari/537.36', 31 | 'Content-Type' : 'application/x-www-form-urlencoded', 32 | 'cache-control': 'max-age=0', 33 | 'accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3', 34 | 'upgrade-insecure-requests':1, 35 | 'accept-language': 'en-GB,en-US;q=0.9,en;q=0.8' 36 | }; 37 | 38 | const headersMT = { 39 | 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/74.0.3703.0 Safari/537.36', 40 | 'Content-Type' : 'application/x-www-form-urlencoded', 41 | 'cache-control': 'max-age=0', 42 | 'accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3', 43 | 'upgrade-insecure-requests':1, 44 | 'accept-language': 'en-GB,en-US;q=0.9,en;q=0.8' 45 | }; 46 | 47 | 48 | function getLocationFromVF(mmsi, cb) { 49 | const url = `https://www.vesselfinder.com/vessels/somestring-MMSI-${mmsi}`; 50 | debug('getLocationFromVF', url); 51 | debug('error VF'); 52 | cb({ error: 'an unknown error occured' }); 53 | } 54 | 55 | function getLocationFromMT(mmsi, cb) { 56 | const url = `https://www.marinetraffic.com/en/data/?asset_type=vessels&columns=flag,shipname,photo,recognized_next_port,reported_eta,reported_destination,current_port,imo,mmsi,ship_type,show_on_live_map,time_of_latest_position,lat_of_latest_position,lon_of_latest_position&mmsi|eq|mmsi=${mmsi}`; 57 | debug('getLocationFromMT', url); 58 | 59 | headers = headersMT; 60 | 61 | const options = { 62 | url, 63 | headers, 64 | }; 65 | 66 | request(options, function (error, response, html) { 67 | 68 | 69 | 70 | if (!error && response.statusCode == 200 || response.statusCode == 403) { 71 | 72 | 73 | console.log('first request successsfull, set cookie'); 74 | let secondRequestHeaders = headers; 75 | secondRequestHeaders.cookie = response.headers['set-cookie']; 76 | secondRequestHeaders.referer = `https://www.marinetraffic.com/en/data/?asset_type=vessels&columns=flag,shipname,photo,recognized_next_port,reported_eta,reported_destination,current_port,imo,mmsi,ship_type,show_on_live_map,time_of_latest_position,lat_of_latest_position,lon_of_latest_position&mmsi|eq|mmsi=${mmsi}`; 77 | secondRequestHeaders['Vessel-Image'] = '007fb60815c6558c472a846479502b668e08'; 78 | 79 | request({ 80 | url: `https://www.marinetraffic.com/en/reports?asset_type=vessels&columns=flag,shipname,photo,recognized_next_port,reported_eta,reported_destination,current_port,imo,mmsi,ship_type,show_on_live_map,time_of_latest_position,lat_of_latest_position,lon_of_latest_position&mmsi=${mmsi}`, 81 | headers: secondRequestHeaders 82 | }, function (error, response, html) { 83 | 84 | if (!error && response.statusCode == 200 || response.statusCode == 403) { 85 | 86 | console.log('second request worked'); 87 | 88 | console.log(html); 89 | 90 | let parsed = JSON.parse(html); 91 | 92 | console.log(parsed); 93 | if (parsed.totalCount > 0) 94 | { 95 | 96 | const latitude = parseFloat(parsed.data[0].LAT); 97 | const longitude = parseFloat(parsed.data[0].LON); 98 | const speed = parseFloat(parsed.data[0].SPEED); 99 | const course = parseFloat(parsed.data[0].COURSE); 100 | 101 | const timestamp = new Date(parsed.data[0].LAST_POS*1000).toISOString(); 102 | const unixtime = new Date(parsed.data[0].LAST_POS*1000).getTime()/1000; 103 | console.log(123); 104 | 105 | //const $ = cheerio.load(html); 106 | console.log(timestamp, speed ,course ,latitude ,longitude) 107 | 108 | if (timestamp && latitude && longitude) { 109 | cb( 110 | parsePosition({ 111 | error: null, 112 | data: { 113 | timestamp: timestamp, 114 | unixtime, 115 | course: course, 116 | speed, 117 | latitude, 118 | longitude, 119 | } 120 | }) 121 | ); 122 | } else { 123 | cb({ error: 'missing needed position data' }); 124 | } 125 | } else { 126 | cb({ error: 'no records were found' }); 127 | } 128 | } else { 129 | cb({ error }); 130 | } 131 | 132 | }); 133 | 134 | } else { 135 | cb({ error }); 136 | } 137 | 138 | 139 | 140 | }); 141 | } 142 | 143 | function getLocation(mmsi, cb) { 144 | debug('getting location for vessel: ', mmsi); 145 | getLocationFromVF(mmsi, function(VFResult) { 146 | debug('got location from vf', VFResult); 147 | 148 | getLocationFromMT(mmsi, function (MTResult) { 149 | if (MTResult.error) { 150 | cb(VFResult); 151 | } else { 152 | debug('got location from mt', MTResult); 153 | if (!VFResult.data) { 154 | return cb(MTResult); 155 | } 156 | const vfDate = moment( VFResult.data.timestamp); 157 | const mtDate = moment(MTResult.data.timestamp); 158 | const secondsDiff = mtDate.diff(vfDate, 'seconds') 159 | debug('time diff in seconds: ', secondsDiff); 160 | 161 | cb(secondsDiff > 0 ? MTResult : VFResult); 162 | } 163 | }); 164 | }); 165 | } 166 | 167 | function getVesselsInPort(shipPort, cb) { 168 | const url = `https://www.marinetraffic.com/en/reports?asset_type=vessels&columns=flag,shipname,photo,recognized_next_port,reported_eta,reported_destination,current_port,imo,ship_type,show_on_live_map,time_of_latest_position,lat_of_latest_position,lon_of_latest_position,current_port_country,notes¤t_port_in_name=${shipPort}`; 169 | debug('getVesselsInPort', url); 170 | 171 | const headers={ 172 | 'accept': '*/*', 173 | 'Accept-Language': 'en-US,en;q=0.5', 174 | 'Accept-Encoding': 'gzip, deflate, brotli', 175 | 'Vessel-Image': '0053e92efe9e7772299d24de2d0985adea14', 176 | 'X-Requested-With': 'XMLHttpRequest' 177 | } 178 | const options = { 179 | url, 180 | headers, 181 | json: true, 182 | gzip: true, 183 | deflate: true, 184 | brotli:true 185 | }; 186 | request(options, function (error, response, html) { 187 | if (!error && response.statusCode == 200 || typeof response != 'undefined' && response.statusCode == 403) { 188 | 189 | 190 | return cb(response.body.data.map((vessel) => ({ 191 | name: vessel.SHIPNAME, 192 | id: vessel.SHIP_ID, 193 | lat: Number(vessel.LAT), 194 | lon: Number(vessel.LON), 195 | timestamp: vessel.LAST_POS, 196 | mmsi: vessel.MMSI, 197 | imo: vessel.IMO, 198 | callsign: vessel.CALLSIGN, 199 | speed: Number(vessel.SPEED), 200 | area: vessel.AREA_CODE, 201 | type: vessel.TYPE_SUMMARY, 202 | country: vessel.COUNTRY, 203 | destination: vessel.DESTINATION, 204 | port_current_id: vessel.PORT_ID, 205 | port_current: vessel.CURRENT_PORT, 206 | port_next_id: vessel.NEXT_PORT_ID, 207 | port_next: vessel.NEXT_PORT_NAME, 208 | }))); 209 | } else { 210 | debug('error in getVesselsInPort'); 211 | cb({ error: 'an unknown error occured' }); 212 | return false; 213 | } 214 | }); 215 | } 216 | 217 | 218 | module.exports = { 219 | getLocationFromVF: getLocationFromVF, 220 | getLocationFromMT: getLocationFromMT, 221 | getLocation: getLocation, 222 | getVesselsInPort:getVesselsInPort, 223 | }; 224 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "AIS API", 3 | "description": "Node AIS API Heroku app", 4 | "logo": "https://cdn.jsdelivr.net/gh/heroku/node-js-getting-started/public/node.svg", 5 | "keywords": ["api", "ais"], 6 | "image": "heroku/nodejs" 7 | } 8 | -------------------------------------------------------------------------------- /area.js: -------------------------------------------------------------------------------- 1 | const scraper = require('./lib/puppeteer'); 2 | 3 | const fetchResultByArea = async (area, time, cb) => { 4 | const result = await scraper.fetch({ 5 | url: `https://www.marinetraffic.com/en/reports?asset_type=vessels&columns=time_of_latest_position:desc,flag,shipname,photo,recognized_next_port,reported_eta,reported_destination,current_port,imo,ship_type,show_on_live_map,area,lat_of_latest_position,lon_of_latest_position&area_in=${area}&time_of_latest_position_between=${time}`, 6 | referer: 'https://www.marinetraffic.com/en/data/?asset_type=vessels&columns=time_of_latest_position:desc,flag,shipname,photo,recognized_next_port,reported_eta,reported_destination,current_port,imo,ship_type,show_on_live_map,area,lat_of_latest_position,lon_of_latest_position&area_in|in|West%20Mediterranean,East%20Mediterranean|area_in=WMED,EMED&time_of_latest_position_between|gte|time_of_latest_position_between=60,525600', 7 | responseSelector: '/en/reports?', 8 | extraHeaders: { 9 | 'vessel-image': '0053e92efe9e7772299d24de2d0985adea14', 10 | }, 11 | }, cb); 12 | } 13 | 14 | const mapResult = (result) => { 15 | return result.data.map((vessel) => ({ 16 | name: vessel.SHIPNAME, 17 | id: vessel.SHIP_ID, 18 | lat: Number(vessel.LAT), 19 | lon: Number(vessel.LON), 20 | timestamp: vessel.LAST_POS, 21 | mmsi: vessel.MMSI, 22 | imo: vessel.IMO, 23 | callsign: vessel.CALLSIGN, 24 | speed: Number(vessel.SPEED), 25 | area: vessel.AREA_CODE, 26 | type: vessel.TYPE_SUMMARY, 27 | country: vessel.COUNTRY, 28 | destination: vessel.DESTINATION, 29 | port_current_id: vessel.PORT_ID, 30 | port_current: vessel.CURRENT_PORT, 31 | port_next_id: vessel.NEXT_PORT_ID, 32 | port_next: vessel.NEXT_PORT_NAME, 33 | })); 34 | } 35 | 36 | const fetchVesselsInArea = (regions = ['WMED','EMED'], cb) => { 37 | const timeframe = [60, 525600]; 38 | fetchResultByArea(regions.join(','), timeframe.join(','), (result) => { 39 | if (!result || !result.data.length) { 40 | return cb(null); 41 | } 42 | return cb(mapResult(result)); 43 | }); 44 | } 45 | 46 | const fetchResultNearMe = async (lat, lng, distance, time, cb) => { 47 | const result = await scraper.fetch({ 48 | url: `https://www.marinetraffic.com/en/reports?asset_type=vessels&columns=time_of_latest_position:desc,flag,shipname,photo,recognized_next_port,reported_eta,reported_destination,current_port,imo,ship_type,show_on_live_map,area,lat_of_latest_position,lon_of_latest_position&time_of_latest_position_between=${time}&near_me=${lat},${lng},${distance}`, 49 | referer: 'https://www.marinetraffic.com/en/data/?asset_type=vessels&columns=time_of_latest_position:desc,flag,shipname,photo,recognized_next_port,reported_eta,reported_destination,current_port,imo,ship_type,show_on_live_map,area,lat_of_latest_position,lon_of_latest_position&area_in|in|West%20Mediterranean,East%20Mediterranean|area_in=WMED,EMED&time_of_latest_position_between|gte|time_of_latest_position_between=60,525600', 50 | responseSelector: '/en/reports?', 51 | extraHeaders: { 52 | 'vessel-image': '0053e92efe9e7772299d24de2d0985adea14', 53 | }, 54 | }, cb); 55 | } 56 | 57 | const fetchVesselsNearMe = (lat = 51.74190, lng = 3.89773, distance = 2, cb) => { 58 | const timeframe = [60, 525600]; 59 | fetchResultNearMe(lat, lng, distance, timeframe.join(','), (result) => { 60 | if (!result || !result.data.length) { 61 | return cb(null); 62 | } 63 | return cb(mapResult(result)); 64 | 65 | }); 66 | } 67 | 68 | module.exports = { 69 | fetchVesselsInArea, 70 | fetchVesselsNearMe, 71 | }; 72 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 |

AIS API

2 |

I looked for a free API solution to access machine readable AIS Data. This solution uses the free web solutions to crawl the data and returns them in json.

3 |

How to use

4 |

This is a nodejs web app.

5 |

Paths

6 |

/getLastPosition/:mmsi

7 |

Takes position from MT and from VT and returns the newest 8 | example: http://host:port/getLastPosition/211281610

9 |

/getLastPositionFromVF/:mmsi

10 |

Returns position from VF 11 | example: http://host:port/getLastPositionFromVF/211281610

12 |

/getLastPositionFromMT/:mmsi

13 |

Returns position from MT 14 | example: http://host:port/getLastPositionFromMT/211281610

15 |

/getVesselsInArea/:area

16 |

Returns all vessels in area, defined by a list of area keywords 17 | example: http://host:port/getVesselsInArea/WMED,EMED

18 |
[{
19 |   name: vessel.SHIPNAME,
20 |   id: vessel.SHIP_ID,
21 |   lat: Number(vessel.LAT),
22 |   lon: Number(vessel.LON),
23 |   timestamp: vessel.LAST_POS,
24 |   mmsi: vessel.MMSI,
25 |   imo: vessel.IMO,
26 |   callsign: vessel.CALLSIGN,
27 |   speed: Number(vessel.SPEED),
28 |   area: vessel.AREA_CODE,
29 |   type: vessel.TYPE_SUMMARY,
30 |   country: vessel.COUNTRY,
31 |   destination: vessel.DESTINATION,
32 |   port_current_id: vessel.PORT_ID,
33 |   port_current: vessel.CURRENT_PORT,
34 |   port_next_id: vessel.NEXT_PORT_ID,
35 |   port_next: vessel.NEXT_PORT_NAME,
36 | },…]
37 | 
38 |

/getVesselsInPort/:shipPort

39 |

Returns all vessels in a port, named after the MT nomenclature 40 | example: http://host:port/getVesselsInPort/piraeus
41 | Output format identical to getVesselsInArea

42 |

Install on local machine

43 |

Requirements: npm & nodejs.

44 |
    45 |
  1. 46 |

    clone this repo

    47 |
  2. 48 |
  3. 49 |

    run npm install

    50 |
  4. 51 |
  5. 52 |

    run node index.js

    53 |
  6. 54 |
55 |

Deploy to heroku

56 |

This application can be easily deployed to heroku, simply install the heroku cli and run the following commands:

57 |

heroku create

58 |

git push heroku master

59 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const server = require('./server'); 2 | 3 | server.init(); 4 | -------------------------------------------------------------------------------- /lib/puppeteer.js: -------------------------------------------------------------------------------- 1 | const puppeteer = require('puppeteer'); 2 | 3 | const scrapeJsonFromResponse = async (options, cb) => { 4 | const browser = await puppeteer.launch({ 5 | args: [ 6 | // Required for Docker version of Puppeteer 7 | '--no-sandbox', 8 | '--disable-setuid-sandbox', 9 | // This will write shared memory files into /tmp instead of /dev/shm, 10 | // because Docker’s default for /dev/shm is 64MB 11 | '--disable-dev-shm-usage' 12 | ] 13 | }); 14 | 15 | const page = await browser.newPage(); 16 | 17 | await page.setExtraHTTPHeaders({ 18 | 'x-requested-with': 'XMLHttpRequest', 19 | 'referer': options.referer, 20 | ...options.extraHeaders, 21 | }); 22 | 23 | page.on('request', (interceptedRequest) => { 24 | const reqUrl = interceptedRequest.url(); 25 | console.log('A request was started: ', reqUrl); 26 | }); 27 | 28 | page.on('requestfinished', async (request) => { 29 | const resUrl = request.url(); 30 | if (resUrl.indexOf(options.responseSelector) !== -1) { 31 | const response = request.response(); 32 | const json = await response.json(); 33 | console.log('A response was received: ', await response.url()); 34 | cb(json); 35 | } 36 | }); 37 | 38 | // Mock real desktop chrome 39 | page.setViewport({ 40 | height: 1302, 41 | width: 2458, 42 | }); 43 | page.setUserAgent('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/71.0.3578.98 Safari/537.36'); 44 | await page.evaluateOnNewDocument(() => { 45 | Object.defineProperty(navigator, "languages", { 46 | get: function() { 47 | return ["en-US", "en", "de-DE"]; 48 | } 49 | }); 50 | 51 | Object.defineProperty(navigator, 'plugins', { 52 | get: function() { 53 | // this just needs to have `length > 0`, but we could mock the plugins too 54 | return [1, 2, 3, 4, 5]; 55 | }, 56 | }); 57 | 58 | const getParameter = WebGLRenderingContext.getParameter; 59 | WebGLRenderingContext.prototype.getParameter = function(parameter) { 60 | // UNMASKED_VENDOR_WEBGL 61 | if (parameter === 37445) { 62 | return 'Intel Open Source Technology Center'; 63 | } 64 | // UNMASKED_RENDERER_WEBGL 65 | if (parameter === 37446) { 66 | return 'Mesa DRI Intel(R) Ivybridge Mobile '; 67 | } 68 | return getParameter(parameter); 69 | }; 70 | }); 71 | 72 | await page.goto(options.url, {'waitUntil': 'networkidle0'}); 73 | 74 | const browserVersion = await browser.version(); 75 | 76 | await browser.close(); 77 | } 78 | 79 | module.exports = { 80 | fetch: scrapeJsonFromResponse, 81 | }; 82 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ais-scraper", 3 | "version": "1.0.0", 4 | "description": "ais scraping script", 5 | "main": "server.js", 6 | "dependencies": { 7 | "brotli": "^1.3.2", 8 | "cheerio": "^1.0.0-rc.2", 9 | "cors": "^2.8.5", 10 | "express": "^4.17.1", 11 | "moment": "^2.29.1", 12 | "puppeteer": "^1.20.0", 13 | "request": "^2.88.2", 14 | "tor-request": "^2.3.1" 15 | }, 16 | "devDependencies": { 17 | "nodemon": "^2.0.15", 18 | "remark-cli": "^10.0.1", 19 | "remark-html": "^13.0.2" 20 | }, 21 | "scripts": { 22 | "dev": "DEBUG=true nodemon --watch ./ --watch lib index.js", 23 | "start": "node index.js", 24 | "test": "echo \"Error: no test specified\" && exit 1", 25 | "parse-readme": "remark readme.md -u html -o index.html" 26 | }, 27 | "keywords": [ 28 | "ais" 29 | ], 30 | "author": "niczem", 31 | "license": "MIT" 32 | } 33 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # AIS API 2 | 3 | This repo is discontinued. Please use [github.com/transparency-everywhere/position-api/](https://github.com/transparency-everywhere/position-api/) in future =) 4 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const path = require('path'); 3 | const api = require('./api'); 4 | const areaApi = require('./area'); 5 | const cors = require("cors"); 6 | 7 | /** 8 | * Inits Database and triggers seeding if no database exists 9 | * @constructor 10 | * @param {int} port - Portnumber 11 | */ 12 | function init(port) { 13 | const app = express(); 14 | 15 | app.set('port', (port ||process.env.PORT || 5000)) 16 | app.use(cors({ 17 | origin: '*' 18 | })); 19 | 20 | app.get('/', (request, response) => { 21 | response.sendFile(path.join(__dirname + '/index.html')); 22 | }); 23 | 24 | app.get('/getLastPositionFromVF/:mmsi', (req, res) => { 25 | api.getLocationFromVF(req.params.mmsi, (result) => { 26 | res.send(result); 27 | }); 28 | }); 29 | 30 | app.get('/getLastPositionFromMT/:mmsi', (req, res) => { 31 | api.getLocationFromMT(req.params.mmsi, (result) => { 32 | res.send(result); 33 | }); 34 | }); 35 | 36 | app.get('/getLastPosition/:mmsi', (req, res) => { 37 | api.getLocation(req.params.mmsi, (result) => { 38 | res.send(result); 39 | }); 40 | }); 41 | 42 | // e.g. /getVesselsInArea/WMED,EMED 43 | app.get('/getVesselsInArea/:area', async (req, res) => { 44 | const result = await areaApi.fetchVesselsInArea(req.params.area.split(','), (result) => { 45 | res.json(result); 46 | }); 47 | }); 48 | 49 | app.get('/getVesselsNearMe/:lat/:lng/:distance', async (req, res) => { 50 | const result = await areaApi.fetchVesselsNearMe(req.params.lat, req.params.lng, req.params.distance, (result) => { 51 | res.json(result); 52 | }); 53 | }); 54 | 55 | app.get('/getVesselsInPort/:shipPort', (req, res) => { 56 | api.getVesselsInPort(req.params.shipPort, (result) => { 57 | res.send(result); 58 | }); 59 | }); 60 | 61 | app.listen(app.get('port'), function() { 62 | console.log('Node app is running on port', app.get('port')); 63 | }); 64 | } 65 | 66 | module.exports = { 67 | init: init, 68 | }; 69 | --------------------------------------------------------------------------------