├── composer.json ├── README.md └── src └── Fnb └── fnb.php /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "codeChap/fnb", 3 | "type": "library", 4 | "description": "PHP First National Bank library", 5 | "keywords": ["fnb", "first national bank"], 6 | "homepage": "https://github.com/codeChap/fnb", 7 | "license": "MIT", 8 | "authors": [ 9 | { 10 | "name": "Derrick Egersdorfer", 11 | "email": "derrick@egersdorfer.co.za", 12 | "role": "Developer" 13 | } 14 | ], 15 | "require": { 16 | "php": ">=5.3.0" 17 | }, 18 | "autoload": { 19 | "psr-0": {"Fnb": "src/"} 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | You might be more interested in developing for https://root.co.za/ 3 | 4 | Fnb 5 | === 6 | 7 | First National Bank Transaction Package. 8 | 9 | ## Disclaimer: 10 | 11 | Use this library at your own risk, I take no responsibility what so ever for the use of it! 12 | 13 | ## What does this do? 14 | 15 | This script logs into your FNB bank account and pulls all your transactions and puts in a pretty php array. 16 | 17 | ## So why an automated login? 18 | 19 | Because pulling a full history of my accounts daily allows for a more creative approach to banking and invoicing. 20 | 21 | ## FNB, please build an API 22 | 23 | First national bank was announced the most innovative bank in the world. The next step in the banking evolution would be to have APIs built into our banking systems. 24 | 25 | ## How long will this script work for? 26 | 27 | Probably not very long, its based on the structure of the HTML pages so if FNB change there website to much - this script will fail miserably. So I would not go building complex systems around it. Thats what APIs are for. 28 | 29 | ## Is this legal? 30 | 31 | At the time of writing, I have not found anything in FNB's terms and conditions regarding automated login. I will however remove this repository if requested to do so. 32 | 33 | ## Usage 34 | 35 | The very, VERY first thing you do is login to FNB as per usual and create a second READ ONLY user for use with this script. The reasons for this are obvious. 36 | 37 | Then use [composer](http://getcomposer.org) to install it or simply include the file somewhere: 38 | 39 | ``` 40 | require("fnb/src/Fnb/Fnb.php"); 41 | 42 | $fnb = new Fnb\Fnb( 43 | array( 44 | 'username' => 'readOnlyUser', 45 | 'password' => 'readOnlyPassword', 46 | 'verbose' => false, 47 | 'write' => false 48 | ) 49 | ); 50 | 51 | $fnb->pull(); 52 | 53 | print "
"; print_r($r); print "
"; 54 | 55 | ``` 56 | 57 | ## Questions 58 | 59 | Ask me on twitter if you have any questions: [@codeChap](http://twitter.com/codechap) 60 | -------------------------------------------------------------------------------- /src/Fnb/fnb.php: -------------------------------------------------------------------------------- 1 | 'readOnlyUser', 17 | * 'password' => 'readOnlyPass', 18 | * 'verbose' => false, 19 | * 'write' => true 20 | * ) 21 | * ); 22 | * 23 | * $fnb->pull(); 24 | * 25 | * print "
"; print_r($r); print "
"; 26 | * 27 | */ 28 | 29 | namespace Fnb; 30 | 31 | class Fnb 32 | { 33 | /** 34 | * Base url 35 | */ 36 | private $base_url = "https://www.online.fnb.co.za/"; 37 | 38 | /** 39 | * The login url 40 | */ 41 | private $login_url = "https://www.online.fnb.co.za/login/Controller"; 42 | 43 | /** 44 | * The login path off of the base url 45 | */ 46 | private $login_two_url = "https://www.online.fnb.co.za/banking/Controller"; 47 | 48 | /** 49 | * My bank accounts button link 50 | */ 51 | private $my_bank_accounts_url = "https://www.online.fnb.co.za/banking/Controller?nav=accounts.summaryofaccountbalances.navigator.SummaryOfAccountBalances&FARFN=4&actionchild=1&isTopMenu=true&targetDiv=workspace"; 52 | 53 | /** 54 | * Downloads url 55 | */ 56 | private $downloads_url = "https://www.online.fnb.co.za/banking/Controller?nav=accounts.transactionhistory.navigator.TransactionHistoryDDADownload&downloadFormat=csv"; 57 | 58 | /** 59 | * The username to login with 60 | */ 61 | private $username = false; 62 | 63 | /** 64 | * The password to login with 65 | */ 66 | private $password = false; 67 | 68 | /** 69 | * Holds Curl 70 | */ 71 | private $ch; 72 | 73 | /** 74 | * Holds an array of results from the curl requests 75 | */ 76 | private $result = array(); 77 | 78 | /** 79 | * Curl should talk more 80 | */ 81 | private $verbose = false; 82 | 83 | /** 84 | * I should talk more 85 | */ 86 | private $write = true; 87 | 88 | /** 89 | * Are we logged in already? 90 | */ 91 | private $login = false; 92 | 93 | /** 94 | * Constrict and set properties 95 | */ 96 | public function __construct( $config ) 97 | { 98 | // Get all variables of this class 99 | $params = array_keys( get_class_vars( get_called_class() ) ); 100 | 101 | // Loop and set config values 102 | foreach( $params as $key ){ 103 | if(array_key_exists($key, $config)){ 104 | if( ! call_user_func(array($this, 'set'.ucFirst(strtolower($key))), $config[$key])){ 105 | throw new \Exception("Could not set " . $key . " to " . $config[$key] . ": " . implode($this->error) ); 106 | } 107 | } 108 | } 109 | } 110 | 111 | /** 112 | * Sets the username to login with 113 | */ 114 | public function setUsername($value) 115 | { 116 | // Set value 117 | $this->username = $value; 118 | 119 | // Done 120 | return $this; 121 | } 122 | 123 | /** 124 | * Sets the password to login with 125 | */ 126 | public function setPassword($value) 127 | { 128 | // Set value 129 | $this->password = $value; 130 | 131 | // Done 132 | return $this; 133 | } 134 | 135 | /** 136 | * Sets the curl verbose mode to true. 137 | */ 138 | public function setVerbose($boolean) 139 | { 140 | // Set true or false 141 | if(is_bool($boolean)){ 142 | 143 | // Set it 144 | $this->verbose = $boolean; 145 | 146 | // Done 147 | return $this; 148 | } 149 | 150 | // Oops 151 | throw new \Exception("Please use a boolean value for verbose setting."); 152 | } 153 | 154 | /** 155 | * Sets the script verbose mode to true. 156 | */ 157 | public function setWrite($boolean) 158 | { 159 | // Set true or false 160 | if(is_bool($boolean)){ 161 | 162 | // Set it 163 | $this->write = $boolean; 164 | 165 | // Done 166 | return $this; 167 | } 168 | 169 | // Oops 170 | throw new \Exception("Please use a boolean value for write setting."); 171 | } 172 | 173 | /** 174 | * Retuens the php temp dir with a trailing slash 175 | */ 176 | public function getTemp() 177 | { 178 | //return rtrim(getcwd(), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR; 179 | return rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR; 180 | } 181 | 182 | /** 183 | * Function that starts the whole process 184 | */ 185 | public function pull() 186 | { 187 | // Check that we not already logged in 188 | if($this->login){ 189 | return true; 190 | } 191 | 192 | // Check for username and password 193 | if( ! $this->username or ! $this->password){ 194 | throw new \Exception("Username and or password not set, can't login without it."); 195 | } 196 | 197 | // STEP ONE: Find all inputs of the homepage // 198 | 199 | // Build login fieds 200 | $fields = array( 201 | "BrowserType" => "undefined", 202 | "BrowserVersion" => "undefined", 203 | "LoginButton" => "Login", 204 | "OperatingSystem" => "undefined", 205 | "Password" => $this->password, 206 | "Username" => $this->username, 207 | "action" => "login", 208 | "bankingUrl" => $this->base_url, 209 | "country" => "15", 210 | "countryCode" => "ZA", 211 | "division" => "", 212 | "form" => "LOGIN_FORM", 213 | "formname" => "LOGIN_FORM", 214 | "homePageLogin" => "true", 215 | "language" => "en", 216 | "multipleSubmit" => "1", 217 | "nav" => "navigator.UserLogon", 218 | "products" => "", 219 | "url" => "0" 220 | ); 221 | 222 | // First call 223 | $this->write('Login into Fnb - Step 1.'); 224 | $result = $this->request($this->login_url, $fields, "POST"); 225 | $inputs = $this->processHtmlResultToFindInputs($result); 226 | 227 | // Second call 228 | $this->write('Login into Fnb - Step 2.'); 229 | $new_inputs = array_merge( 230 | $inputs, 231 | array( 232 | "OperatingSystem" => "MacIntel", 233 | "BrowserType" => "Firefox", 234 | "BrowserVersion" => "26.0", 235 | "BrowserHeight" => "0", 236 | "BrowserWidth" => "0", 237 | "isMobile" => "false" 238 | ) 239 | ); 240 | $this->request($this->login_two_url, $new_inputs, "GET"); 241 | 242 | // Third call 243 | $this->write('Clicking on the "My Bank Accounts" Button.'); 244 | $data_array = array( 245 | "FARFN" => 4, 246 | "actionchild" => 1, 247 | "isTopMenu" => "true", 248 | "nav" => "accounts.summaryofaccountbalances.navigator.SummaryOfAccountBalances", 249 | "targetDiv" => "workspace" 250 | ); 251 | $result = $this->request($this->my_bank_accounts_url, $data_array, "POST"); 252 | $links_to_accounts = $this->processAccountsPageToFindLinks($result); 253 | 254 | // Click on each bank account 255 | foreach($links_to_accounts as $link){ 256 | 257 | // Count 258 | $n = isset($n) ? $n + 1 : 1; 259 | 260 | // Build full link 261 | $link = $this->base_url . $link; 262 | 263 | // Break it up 264 | parse_str(parse_url($link, PHP_URL_QUERY), $params); 265 | 266 | // Info 267 | $this->write(' -> ' . 'downloading csv file ('.$n.')'); 268 | 269 | // Go 270 | $this->request($link, $params, "POST"); 271 | 272 | // Do it 273 | $this->request($this->downloads_url, array(), "POST", $n.".zip"); 274 | 275 | // Unzip it 276 | $this->unzip($n . ".zip"); 277 | } 278 | 279 | // Now that we have csv files, read them in 280 | $result = $this->readin(); 281 | 282 | // Done 283 | return $result; 284 | } 285 | 286 | /** 287 | * Performs curl requests to FNB 288 | */ 289 | private function request($url, $postFieldsArray = array(), $method = false, $asFile = false) 290 | { 291 | // Fire up curl for first time 292 | if( ! is_resource($this->ch) ) { 293 | 294 | // Cookie folder and name 295 | $cookie = $this->getTemp()."fnb_cookie.txt"; 296 | 297 | // Start curl 298 | $this->ch = curl_init(); 299 | 300 | // Set curl options 301 | curl_setopt($this->ch, CURLOPT_VERBOSE, $this->verbose); 302 | curl_setopt($this->ch, CURLOPT_SSL_VERIFYPEER, false); 303 | curl_setopt($this->ch, CURLOPT_HEADER, 0); 304 | curl_setopt($this->ch, CURLOPT_RETURNTRANSFER, true); 305 | curl_setopt($this->ch, CURLOPT_TIMEOUT, 30); 306 | curl_setopt($this->ch, CURLOPT_FOLLOWLOCATION, true); 307 | curl_setopt($this->ch, CURLOPT_COOKIEJAR, $cookie); 308 | curl_setopt($this->ch, CURLOPT_COOKIEFILE, $cookie); 309 | curl_setopt($this->ch, CURLOPT_COOKIESESSION, true); 310 | } 311 | 312 | // Handle fields 313 | $postFields = http_build_query($postFieldsArray); 314 | 315 | // Post request 316 | switch($method){ 317 | 318 | case "POST" : 319 | curl_setopt($this->ch, CURLOPT_POSTFIELDS, $postFields); 320 | curl_setopt($this->ch, CURLOPT_POST, true); 321 | break; 322 | 323 | case "GET" : 324 | $url .= "?" . $postFields; 325 | curl_setopt($this->ch, CURLOPT_POSTFIELDS, false); 326 | curl_setopt($this->ch, CURLOPT_POST, false); 327 | break; 328 | 329 | default : 330 | throw new \Exception('Please set the correct request type: POST or GET'); 331 | 332 | } 333 | 334 | // As a download? 335 | if($asFile){ 336 | $fp = fopen($this->getTemp() . $asFile, 'w'); 337 | curl_setopt($this->ch, CURLOPT_FILE, $fp); 338 | } 339 | 340 | // Do it 341 | curl_setopt($this->ch, CURLOPT_URL, $url); 342 | 343 | // Get result of the request 344 | $result = curl_exec($this->ch); 345 | 346 | // Close as download 347 | if($asFile){ 348 | curl_setopt($this->ch, CURLOPT_FILE, STDOUT); 349 | curl_setopt($this->ch, CURLOPT_RETURNTRANSFER, true); 350 | fclose($fp); 351 | } 352 | 353 | // Done 354 | return $result; 355 | } 356 | 357 | /** 358 | * Processes html requests finds all inputs. 359 | */ 360 | private function processHtmlResultToFindInputs($html) 361 | { 362 | // Parsing the html 363 | $dom = new \DOMDocument(); 364 | $dom->preserveWhiteSpace = false; 365 | @$dom->loadHTML($html); 366 | $tags = $dom->getElementsByTagName('input'); 367 | $array = array(); 368 | 369 | // Loop tags 370 | foreach($tags as $key => $tag){ 371 | if( $name = $tag->getAttribute('name') ){ 372 | $array[$tag->getAttribute('name')] = $tag->getAttribute('value'); 373 | } 374 | } 375 | 376 | // Finish up 377 | if(count($array)){ 378 | return $array; 379 | } 380 | else{ 381 | throw new \Exception("Could not process html from the auth request."); 382 | } 383 | } 384 | 385 | /** 386 | * Find links to accounts by looking for a tags with .blackAnchor class 387 | */ 388 | private function processAccountsPageToFindLinks($html) 389 | { 390 | // Parsing the html 391 | $dom = new \DOMDocument(); 392 | $dom->preserveWhiteSpace = false; 393 | @$dom->loadHTML($html); 394 | $tags = $dom->getElementsByTagName('a'); 395 | $array = array(); 396 | 397 | // Loop 398 | foreach($tags as $key => $tag){ 399 | 400 | // Check for hrefs 401 | if( $onclick = $tag->getAttribute('onclick') ){ 402 | 403 | if(preg_match("/nav=transactionhistory/", $onclick)){ 404 | $array[] = str_replace( 405 | array( 406 | "fnb.controls.controller.eventsObject.raiseEvent('loadResultScreen','/", 407 | "');; return false;" 408 | ), 409 | array("", ""), 410 | $onclick 411 | ); 412 | } 413 | } 414 | } 415 | 416 | // Finish up 417 | if(count($array)){ 418 | return array_unique($array); 419 | } 420 | else{ 421 | throw new \Exception("Could not process html from the accounts page."); 422 | } 423 | } 424 | 425 | /** 426 | * Function to unzip a file 427 | */ 428 | private function unzip($file) 429 | { 430 | // Build full file path 431 | $zipFile = $this->getTemp() . $file; 432 | 433 | // Upzup it 434 | $zip = new \ZipArchive; 435 | $res = $zip->open($zipFile); 436 | if ($res === TRUE) { 437 | $zip->extractTo($this->getTemp()); 438 | $zip->close(); 439 | $this->write(' -> unziped ' . $file); 440 | } 441 | else{ 442 | throw new \Exception('Cound not unzip ' . $zipFile); 443 | } 444 | } 445 | 446 | /** 447 | * Scann the temp folder for .csv file and returns them. 448 | */ 449 | private function readin() 450 | { 451 | // Scan temp folder 452 | if ($handle = opendir($this->getTemp())) { 453 | while (false !== ($entry = readdir($handle))) { 454 | if (substr($entry, -4) == '.csv') { 455 | $found[] = $entry; 456 | } 457 | } 458 | closedir($handle); 459 | } 460 | 461 | // Handle CSV 462 | if(count($found)){ 463 | 464 | // Loop found CSV files 465 | foreach($found as $file){ 466 | 467 | // Reset row variables 468 | $row = 0; 469 | $trow = 0; 470 | 471 | // Open file 472 | if (($handle = fopen($this->getTemp().$file, "r")) !== FALSE) { 473 | 474 | // Read it 475 | while (($data = fgetcsv($handle, 0, ",")) !== FALSE) { 476 | 477 | // Count the row 478 | $row = isset($row) ? $row + 1 : 1; 479 | 480 | // Check the first row for 481 | if($row == 1){ 482 | if( trim($data[0]) !== "ACCOUNT TRANSACTION HISTORY"){ 483 | $this->write($file . " is not a FNB file, skipping it."); 484 | //throw new \Notice(""); 485 | fclose($handle); 486 | continue; 487 | } 488 | } 489 | 490 | // Find account number on row 4, col 2 491 | if($row == 4){ 492 | $account = trim($data[1]); 493 | } 494 | 495 | if($row == 7){ 496 | 497 | // Count data 498 | $num = count($data); 499 | 500 | // Build headers 501 | for ($c=0; $c < $num; $c++) { 502 | $headers[$c] = strtolower(trim($data[$c])); 503 | } 504 | } 505 | 506 | // Wanted data starts on row eight, hopefully for all account types? 507 | if($row > 7){ 508 | 509 | // Count data 510 | $num = count($data); 511 | 512 | // Rows from seven onward 513 | $trow++; 514 | 515 | // We assume the order is: date, amount, balance, description - again for all account types. 516 | for ($c=0; $c < $num; $c++) { 517 | $array[$account][$trow][ $headers[$c] ] = preg_replace('/\s+/', ' ', trim($data[$c]) ); 518 | } 519 | } 520 | } 521 | 522 | // Close file and clean up 523 | fclose($handle); 524 | 525 | // Info 526 | $this->write('Extracted from '.$file.'.'); 527 | } 528 | } 529 | 530 | // Done 531 | return $array; 532 | } 533 | } 534 | 535 | /** 536 | * Write to the command line 537 | */ 538 | function write($msg) 539 | { 540 | $this->write ? fwrite(STDOUT, "\033[36m" . $msg . "\033[0m" . PHP_EOL) : null; 541 | } 542 | } --------------------------------------------------------------------------------