├── .env ├── src ├── components │ ├── AdDetail │ │ ├── AdDetail.module.css │ │ └── index.jsx │ ├── Tools │ │ ├── Tools.module.css │ │ └── index.jsx │ ├── Login │ │ ├── Login.module.css │ │ └── index.jsx │ ├── Ad │ │ ├── Ad.module.css │ │ ├── AdDetails │ │ │ ├── AdDetails.module.css │ │ │ └── index.jsx │ │ ├── index.jsx │ │ └── fb_ad.scss │ ├── AdSearch │ │ ├── AdSearch.module.css │ │ ├── index.jsx │ │ └── sample_ad.json │ ├── Targets │ │ ├── Targets.module.css │ │ └── index.jsx │ ├── Credits │ │ └── index.jsx │ ├── Layout │ │ ├── Layout.module.css │ │ └── index.jsx │ ├── AdWrapper │ │ └── index.jsx │ ├── Advertiser │ │ ├── Advertiser.module.css │ │ └── index.jsx │ ├── Payer │ │ ├── Payer.module.css │ │ └── index.jsx │ ├── Search │ │ └── index.jsx │ ├── Topics │ │ └── index.jsx │ └── constants.js ├── utils │ ├── index.js │ └── withURLSearchParams.js ├── setupTests.js ├── App.js ├── index.js ├── routes │ └── index.jsx ├── api │ └── index.jsx └── serviceWorker.js ├── public ├── robots.txt ├── favicon.ico ├── manifest.json └── index.html ├── deploy.sh ├── .gitignore ├── LICENSE ├── package.json ├── .eslintrc └── README.md /.env: -------------------------------------------------------------------------------- 1 | NODE_PATH = src/ -------------------------------------------------------------------------------- /src/components/AdDetail/AdDetail.module.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/Tools/Tools.module.css: -------------------------------------------------------------------------------- 1 | .tools { 2 | margin-top: 1.5em; 3 | } -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: / -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/quartz/pol-ad-dashboard/master/public/favicon.ico -------------------------------------------------------------------------------- /src/components/Login/Login.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | margin: 10% auto; 3 | max-width: 500px; 4 | } -------------------------------------------------------------------------------- /deploy.sh: -------------------------------------------------------------------------------- 1 | npm run build 2 | aws s3 --region us-east-2 rm s3://pol-ad-dashboard 3 | aws s3 --region us-east-2 sync build/ s3://pol-ad-dashboard 4 | -------------------------------------------------------------------------------- /src/components/Ad/Ad.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | display: inline-block; 3 | vertical-align: top; 4 | margin: 6px; 5 | box-shadow: -3px 6px 14px 0px rgba(0,0,0,0.1) 6 | } 7 | -------------------------------------------------------------------------------- /src/components/AdSearch/AdSearch.module.css: -------------------------------------------------------------------------------- 1 | .meta-container { 2 | display: flex; 3 | justify-content: space-between; 4 | align-items: center; 5 | 6 | } 7 | 8 | .meta-title { 9 | margin: 0; 10 | } 11 | -------------------------------------------------------------------------------- /src/utils/index.js: -------------------------------------------------------------------------------- 1 | export { default as withURLSearchParams } from './withURLSearchParams'; 2 | 3 | export const compose = ( ...functions ) => functions.reduce( ( accum, curr ) => ( ...args ) => accum( curr( ...args ) ), arg => arg ); 4 | -------------------------------------------------------------------------------- /src/components/Targets/Targets.module.css: -------------------------------------------------------------------------------- 1 | .button-group { 2 | margin: 4px 12px; 3 | } 4 | 5 | .search-targets > .container { 6 | margin: 0px -12px; 7 | } 8 | 9 | .inad .redx { 10 | display: none; 11 | } 12 | .inad:hover .redx { 13 | display: block; 14 | } -------------------------------------------------------------------------------- /src/setupTests.js: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom/extend-expect'; 6 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Dashboard", 3 | "name": "QZ Political Ad Dashboard", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": ".", 12 | "background_color": "#ffffff" 13 | } 14 | -------------------------------------------------------------------------------- /src/App.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { BrowserRouter as Router } from 'react-router-dom'; 3 | import Routes from './routes'; 4 | // eslint-disable-next-line 5 | import API from 'api'; 6 | 7 | function App() { 8 | return ( 9 |
10 | 11 | 12 | 13 | 14 | 15 |
16 | ); 17 | } 18 | 19 | export default App; 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /src/components/Credits/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const Credits = () => ( 4 |
5 |

Credits

6 |

Targeting data and images provieded by participants in the Political Ad Collector project by Quartz. Other ad data via Facebook, provided by the Online Political Ads Transparency Project at the NYU Tandon School of Engineering.

7 |
8 | ); 9 | 10 | export default Credits; 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import App from './App'; 4 | import * as serviceWorker from './serviceWorker'; 5 | 6 | ReactDOM.render( , document.getElementById( 'root' ) ); 7 | 8 | // If you want your app to work offline and load faster, you can change 9 | // unregister() to register() below. Note this comes with some pitfalls. 10 | // Learn more about service workers: https://bit.ly/CRA-PWA 11 | serviceWorker.unregister(); 12 | -------------------------------------------------------------------------------- /src/components/Ad/AdDetails/AdDetails.module.css: -------------------------------------------------------------------------------- 1 | .details-container { 2 | background-color: #F1F4F8; 3 | padding: 12px; 4 | margin: 10px; 5 | border-radius: 0 0 4px 4px; 6 | display: inline-block; 7 | vertical-align: top; 8 | box-shadow: -3px 6px 14px 0px rgba(0,0,0,0.1) 9 | } 10 | 11 | .title, .paid-for, .text, .sub { 12 | margin: 10px 0; 13 | width: 500px; 14 | } 15 | 16 | .text { 17 | font-weight: normal; 18 | } 19 | 20 | .sub { 21 | color: #B2B2B2; 22 | } 23 | 24 | .modal-content { 25 | display: flex; 26 | margin: 12px; 27 | } 28 | 29 | .right-rail { 30 | margin: 12px; 31 | } 32 | -------------------------------------------------------------------------------- /src/routes/index.jsx: -------------------------------------------------------------------------------- 1 | import React, { Fragment } from 'react'; 2 | import { Redirect, Route } from 'react-router-dom'; 3 | import Layout from 'components/Layout'; 4 | import AdSearch from 'components/AdSearch'; 5 | import Advertiser from 'components/Advertiser'; 6 | import Payer from 'components/Payer'; 7 | import AdDetail from 'components/AdDetail'; 8 | 9 | const Routes = () => ( 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | ) 22 | 23 | export default Routes; 24 | -------------------------------------------------------------------------------- /src/components/Layout/Layout.module.css: -------------------------------------------------------------------------------- 1 | .layout { 2 | display: flex; 3 | overflow-y: scroll; 4 | max-width: 2200px; 5 | } 6 | 7 | .left-rail { 8 | width: 20%; 9 | min-width: 300px; 10 | min-height: 100vh; 11 | padding: 20px; 12 | display: flex; 13 | flex-direction: column; 14 | flex-shrink: 0; 15 | overflow-x: scroll; 16 | } 17 | 18 | .content { 19 | padding: 20px; 20 | background-color: #EEEEEE; 21 | flex-grow: 1; 22 | } 23 | 24 | .target-list { 25 | list-style: none; 26 | padding: 0; 27 | } 28 | 29 | .target-item, .target-group { 30 | margin: 12px 0; 31 | } 32 | 33 | .target-details { 34 | font-size: 16px; 35 | } 36 | 37 | .target-details:hover { 38 | cursor: pointer; 39 | } 40 | 41 | .target-details > summary:focus { 42 | outline: none; 43 | } 44 | 45 | .checkbox { 46 | margin-bottom: 5px; 47 | } -------------------------------------------------------------------------------- /src/components/AdWrapper/index.jsx: -------------------------------------------------------------------------------- 1 | 2 | import React from 'react'; 3 | import PropTypes from 'prop-types'; 4 | import Ad from 'components/Ad'; 5 | 6 | // use this for conditional logic to return consistent Ad component despite different possible types of ads as props 7 | const AdWrapper = ( { adData } ) => adData.map( ( ad, idx ) => { 8 | const { ads, text } = ad; 9 | 10 | if ( !ads ) { 11 | return ( 12 | 13 | ); 14 | } 15 | 16 | const creativeAd = ads.find( ad => ad.html ); 17 | return ( 18 | 19 | ); 20 | } ); 21 | 22 | AdWrapper.defaultProps = { 23 | adData: [], 24 | }; 25 | 26 | AdWrapper.propTypes = { 27 | adData: PropTypes.array, 28 | }; 29 | 30 | export default AdWrapper; 31 | -------------------------------------------------------------------------------- /src/components/Advertiser/Advertiser.module.css: -------------------------------------------------------------------------------- 1 | .advertiser-container { 2 | background-color: lightgrey; 3 | width: 100%; 4 | padding: 12px; 5 | margin-bottom: 12px; 6 | border-radius: 4px; 7 | overflow: auto; 8 | } 9 | 10 | .comma { 11 | margin-right: 4px; 12 | } 13 | 14 | .adv-section { 15 | float: left; 16 | margin-right: 20px; 17 | } 18 | 19 | .topics { 20 | width: 40%; 21 | } 22 | 23 | .topic-label { 24 | display: flex; 25 | justify-content: space-between; 26 | margin-bottom: 2px; 27 | } 28 | 29 | .spend { 30 | float: none; 31 | margin-bottom: 12px; 32 | } 33 | 34 | .targeting { 35 | width: 50%; 36 | } 37 | 38 | .summary { 39 | font-size: 15px; 40 | font-weight: bold; 41 | margin-top: 12px; 42 | } 43 | 44 | .summary:hover { 45 | cursor: pointer; 46 | } 47 | 48 | .summary:focus { 49 | outline: none; 50 | } -------------------------------------------------------------------------------- /src/components/Payer/Payer.module.css: -------------------------------------------------------------------------------- 1 | .payer-container { 2 | background-color: lightgrey; 3 | width: 100%; 4 | padding: 12px; 5 | margin-bottom: 12px; 6 | border-radius: 4px; 7 | overflow: auto; 8 | } 9 | 10 | .comma { 11 | margin-right: 4px; 12 | } 13 | 14 | .adv-section { 15 | float: left; 16 | margin-right: 20px; 17 | } 18 | 19 | .topics { 20 | width: 40%; 21 | } 22 | 23 | .topic-label { 24 | display: flex; 25 | justify-content: space-between; 26 | margin-bottom: 2px; 27 | } 28 | 29 | .spend { 30 | float: none; 31 | margin-bottom: 12px; 32 | } 33 | 34 | .targeting { 35 | width: 50%; 36 | } 37 | 38 | .summary { 39 | font-size: 15px; 40 | font-weight: bold; 41 | margin-top: 12px; 42 | } 43 | 44 | .summary:hover { 45 | cursor: pointer; 46 | } 47 | 48 | .summary:focus { 49 | outline: none; 50 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2020 Quartz 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | 9 | 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pol-ad-dashboard", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@testing-library/jest-dom": "^4.2.4", 7 | "@testing-library/react": "^9.4.0", 8 | "@testing-library/user-event": "^7.2.1", 9 | "classnames": "^2.2.6", 10 | "node-sass": "^4.13.1", 11 | "react": "^16.12.0", 12 | "react-dom": "^16.12.0", 13 | "react-router-dom": "^5.1.2", 14 | "react-scripts": "^3.4.0", 15 | "semantic-ui-react": "^0.88.2" 16 | }, 17 | "scripts": { 18 | "start": "NODE_ENV=development react-scripts start", 19 | "build": "react-scripts build", 20 | "test": "react-scripts test", 21 | "eject": "react-scripts eject" 22 | }, 23 | "eslintConfig": { 24 | "extends": "react-app" 25 | }, 26 | "browserslist": { 27 | "production": [ 28 | ">0.2%", 29 | "not dead", 30 | "not op_mini all" 31 | ], 32 | "development": [ 33 | "last 1 chrome version", 34 | "last 1 firefox version", 35 | "last 1 safari version" 36 | ] 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/components/Search/index.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, Fragment, useEffect } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { Form, Input } from 'semantic-ui-react'; 4 | import { withURLSearchParams } from 'utils'; 5 | 6 | const Search = ( { location: { search }, setParam, getParam } ) => { 7 | const [ searchTerm, setSearchTerm ] = useState( getParam( 'search' ) || '' ); 8 | 9 | useEffect( () => { 10 | if ( !search ) { 11 | setSearchTerm( '' ); 12 | } 13 | }, [ search ] ); 14 | 15 | return ( 16 | 17 |
setParam( 'search', searchTerm )}> 18 | setSearchTerm( e.target.value )} 22 | value={searchTerm} 23 | fluid 24 | /> 25 |
26 |
27 | ); 28 | }; 29 | 30 | Search.propTypes = { 31 | getParam: PropTypes.func.isRequired, 32 | location: PropTypes.shape( { 33 | search: PropTypes.string, 34 | } ), 35 | setParam: PropTypes.func.isRequired, 36 | }; 37 | 38 | export default withURLSearchParams( Search ); 39 | -------------------------------------------------------------------------------- /src/components/Login/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import classnames from 'classnames/bind'; 4 | import { Form, Loader } from 'semantic-ui-react'; 5 | import styles from './Login.module.css'; 6 | 7 | const cx = classnames.bind( styles ); 8 | 9 | const Login = ( { handleChange, loading, onSubmit } ) => { 10 | if ( loading ) { 11 | return ( 12 | 13 | ); 14 | } 15 | 16 | return ( 17 |
18 |
19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | Submit 28 |
29 |
30 | ); 31 | }; 32 | 33 | Login.propTypes = { 34 | handleChange: PropTypes.func.isRequired, 35 | loading: PropTypes.bool.isRequired, 36 | onSubmit: PropTypes.func.isRequired, 37 | }; 38 | 39 | export default Login; 40 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "@quartz/eslint-config-react" 4 | ], 5 | "parser": "babel-eslint", 6 | "rules": { 7 | "react/prop-types": 0, 8 | "jsx-a11y/label-has-associated-control": 0, 9 | "jsx-a11y/label-has-for": 0 10 | }, 11 | "settings": { 12 | "import/resolver": { 13 | "alias": { 14 | "map": [ 15 | [ 16 | "components", 17 | "./src/components" 18 | ], 19 | [ 20 | "routes", 21 | "./src/routes" 22 | ], 23 | [ 24 | "api", 25 | "./src/api" 26 | ], 27 | [ 28 | "utils", 29 | "./src/utils" 30 | ] 31 | ], 32 | "extensions": [ 33 | ".js", 34 | ".jsx", 35 | ".json" 36 | ] 37 | } 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/components/Ad/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import AdDetails, { CreativeAd } from './AdDetails'; 4 | import classnames from 'classnames/bind'; 5 | import Targets from '../Targets'; 6 | import styles from './Ad.module.css'; 7 | // Facebook-ad specific styling 8 | // eslint-disable-next-line 9 | import './fb_ad.scss'; 10 | 11 | const cx = classnames.bind( styles ); 12 | 13 | const Ad = ( { ad, creativeAd, text } ) => { 14 | const { 15 | html, 16 | targets, 17 | targetings, 18 | } = creativeAd; 19 | 20 | if ( !html ) { 21 | return ; 22 | } 23 | 24 | return ( 25 |
26 | 27 | { 28 | targetings && targetings[0] && targetings[0][0] === '<' // cleanup since sometimes an ad target isn't html 29 | ? ( 30 |
31 | ) : null 32 | } 33 | { 34 | targets && targets[0] 35 | ? ( 36 | 37 | ) : null 38 | } 39 | 40 |
41 | ); 42 | }; 43 | 44 | Ad.propTypes = { 45 | ad: PropTypes.object, 46 | creativeAd: PropTypes.object, 47 | text: PropTypes.string, 48 | }; 49 | 50 | export default Ad; 51 | -------------------------------------------------------------------------------- /src/components/Topics/index.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { Dropdown } from 'semantic-ui-react'; 4 | import { compose, withURLSearchParams } from 'utils'; 5 | import { withAPI } from 'api'; 6 | 7 | const topicOptions = ( topics ) => topics 8 | .map( ( [ topic, topicId ] ) => ( { key: topic, value: topicId, text: topic } ) ) 9 | .sort( ( { key: keyA }, { key: keyB } ) => keyA > keyB ? 1 : -1 ); 10 | 11 | const TopicsFilter = ( { getTopics: getTopicsFromAPI, setParam } ) => { 12 | const [ topics, setTopics ] = useState( [] ); 13 | 14 | useEffect( () => { 15 | const getTopics = async () => { 16 | const { topics } = await getTopicsFromAPI(); 17 | const topicValues = topicOptions( topics ); 18 | setTopics( topicValues ); 19 | }; 20 | getTopics(); 21 | }, [ getTopicsFromAPI ] ); 22 | 23 | return ( 24 |
25 |

Topic:

26 | setParam( 'topic_id', data.value )} 31 | placeholder="Topic" 32 | search 33 | selection 34 | /> 35 |
36 | ); 37 | }; 38 | 39 | TopicsFilter.propTypes = { 40 | getTopics: PropTypes.func.isRequired, 41 | setParam: PropTypes.func.isRequired, 42 | }; 43 | 44 | export default compose( withAPI, withURLSearchParams )( TopicsFilter ); 45 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 13 | 17 | 18 | 27 | QZ Political Ad Dashboard 28 | 29 | 30 | 31 |
32 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /src/components/AdDetail/index.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { Link, useParams } from 'react-router-dom'; 4 | import { Progress } from 'semantic-ui-react'; 5 | import classnames from 'classnames/bind'; 6 | import Targets from 'components/Targets'; 7 | import AdWrapper from 'components/AdWrapper'; 8 | import styles from './AdDetail.module.css'; 9 | import { withAPI } from 'api'; 10 | 11 | const cx = classnames.bind( styles ); 12 | 13 | const AdDetail = ( { getAdByTextHash } ) => { 14 | const [ adData, setAdData ] = useState( null ); 15 | const { ad_hash } = useParams(); 16 | 17 | useEffect( () => { 18 | const getAdData = async () => { 19 | const data = await getAdByTextHash( ad_hash ); 20 | setAdData( data ); 21 | }; 22 | getAdData(); 23 | }, [ ad_hash, getAdByTextHash ] ); 24 | 25 | if ( !adData ) { 26 | return ( 27 |
28 |
29 |

{ad_hash}

30 |
31 |
32 | ); 33 | } 34 | const { 35 | text, 36 | fbpac_ads_count, 37 | api_ads_count, 38 | min_spend, 39 | max_spend, 40 | min_impressions, 41 | max_impressions, 42 | ad 43 | } = adData; 44 | 45 | 46 | return ( 47 |
48 |
49 |

Ad Details

50 |

51 |

52 |
{min_spend ? `$${min_spend.toString().replace( /(\d)(?=(\d{3})+(?!\d))/g, '$1,' )} - $${max_spend.toString().replace( /(\d)(?=(\d{3})+(?!\d))/g, '$1,' )} spent` : 'Unknown spend'}
53 |
{min_impressions ? `${min_impressions.toString().replace( /(\d)(?=(\d{3})+(?!\d))/g, '$1,' )} - ${max_impressions.toString().replace( /(\d)(?=(\d{3})+(?!\d))/g, '$1,' )} impressions` : 'unknown impressions'}
54 |
{api_ads_count || 0} FB API ads
55 |
{fbpac_ads_count || 0} FBPAC ads
56 |
First seen: {ad["created_at"] || null}
57 |
Last seen: {ad["updated_at"] || null}
58 | 64 |
65 | ); 66 | }; 67 | 68 | AdDetail.propTypes = { 69 | getAdByTextHash: PropTypes.func.isRequired, 70 | }; 71 | 72 | export default withAPI( AdDetail ); 73 | -------------------------------------------------------------------------------- /src/utils/withURLSearchParams.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { withRouter } from 'react-router-dom'; 3 | 4 | const withURLSearchParams = WrappedComponent => { 5 | class ComponentWithURLSearchParams extends Component { 6 | constructor( props ) { 7 | super( props ); 8 | const { location: { search } } = props; 9 | this.state = { 10 | params: new URLSearchParams( search ), 11 | }; 12 | } 13 | 14 | componentDidMount() { 15 | const { location: { search } } = this.props; 16 | this.setState( { params: new URLSearchParams( search ) } ); 17 | } 18 | 19 | componentDidUpdate( prevProps ) { 20 | const { location: { search } } = this.props; 21 | const { location: { search: prevSearch } } = prevProps; 22 | if ( search !== prevSearch ) { 23 | this.setState( { params: new URLSearchParams( search ) } ); 24 | } 25 | } 26 | 27 | getParam = ( param ) => this.state.params.get( param ) 28 | 29 | getFormattedParams = () => { 30 | const { params } = this.state; 31 | const formattedParams = {}; 32 | const keys = params.keys(); 33 | for ( const key of keys ) { 34 | formattedParams[key] = params.get( key ).split( ',' ); 35 | } 36 | return formattedParams; 37 | } 38 | 39 | toggleParam = ( param ) => { 40 | const { params } = this.state; 41 | const existingParam = params.get( param ); 42 | if ( !existingParam ) { 43 | params.set( param, true ); 44 | } else { 45 | params.delete( param ); 46 | } 47 | params.delete( 'page' ); 48 | // history.push( { pathname, search: params.toString() } ); 49 | this.pushParams( params ); 50 | } 51 | 52 | setParam = ( param, value ) => { 53 | const { params } = this.state; 54 | if ( !value ) { 55 | params.delete( param ); 56 | } else { 57 | params.set( param, value ); 58 | } 59 | // if we're changing anything but page, go back to page 1 60 | if ( param !== 'page' ) { 61 | params.delete( 'page' ); 62 | } 63 | // history.push( { pathname, search: params.toString() } ); 64 | this.pushParams( params ); 65 | } 66 | 67 | pushParams = ( params ) => { 68 | const { history } = this.props; 69 | this.setState( { params: new URLSearchParams( params ) }, () => history.push( { search: params.toString() } ) ) 70 | } 71 | 72 | render() { 73 | const funcs = { 74 | getParam: this.getParam, 75 | getFormattedParams: this.getFormattedParams, 76 | setParam: this.setParam, 77 | toggleParam: this.toggleParam, 78 | }; 79 | return ( 80 | 84 | ); 85 | } 86 | } 87 | 88 | return withRouter( ComponentWithURLSearchParams ); 89 | }; 90 | 91 | export default withURLSearchParams; 92 | -------------------------------------------------------------------------------- /src/components/Tools/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styles from './Tools.module.css'; 3 | import classnames from 'classnames/bind'; 4 | 5 | const cx = classnames.bind( styles ); 6 | 7 | const Tools = () => ( 8 | ); 40 | 41 | export default Tools; 42 | -------------------------------------------------------------------------------- /src/components/Layout/index.jsx: -------------------------------------------------------------------------------- 1 | import React, { Fragment } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { Button, Checkbox, Divider } from 'semantic-ui-react'; 4 | import { withURLSearchParams } from 'utils'; 5 | import { COMMON_TARGETS_GROUPED } from '../constants'; 6 | import Targets, { TargetFilters } from 'components/Targets'; 7 | import Topics from 'components/Topics'; 8 | import Tools from 'components/Tools' 9 | import Credits from 'components/Credits' 10 | import classnames from 'classnames/bind'; 11 | import Search from 'components/Search'; 12 | import styles from './Layout.module.css'; 13 | 14 | const cx = classnames.bind( styles ); 15 | 16 | const CommonTargets = () => ( 17 |
18 |

Common Targets:

19 |
    20 | { 21 | Object.keys( COMMON_TARGETS_GROUPED ).sort().map( ( target, idx ) => { 22 | const vals = COMMON_TARGETS_GROUPED[target].map( val => ( { target, segment: val } ) ); 23 | return ( 24 |
  • 25 |
    26 | 27 | {target} 28 | 29 |
    30 | 31 |
    32 |
    33 |
  • 34 | ); 35 | } ) 36 | } 37 |
38 |
39 | ); 40 | 41 | const Layout = ( { 42 | children, 43 | getParam, 44 | history, 45 | location: { search, pathname }, 46 | toggleParam, 47 | } ) => ( 48 |
49 |
50 |

Quartz FB ads dashboard

51 | { 52 | pathname === '/search' && ( 53 | 54 | 57 | 58 | 59 | ) 60 | } 61 | 62 | 63 | toggleParam( 'only_fbpac' )} 67 | className={cx('checkbox')} 68 | /> 69 | toggleParam( 'no_payer' )} 73 | className={cx('checkbox')} 74 | /> 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 |
84 |
85 | { 86 | children 87 | } 88 |
89 |
90 | ); 91 | 92 | Layout.propTypes = { 93 | children: PropTypes.node, 94 | getParam: PropTypes.func.isRequired, 95 | history: PropTypes.object.isRequired, 96 | location: PropTypes.shape( { 97 | search: PropTypes.string, 98 | pathname: PropTypes.string.isRequired, 99 | } ), 100 | toggleParam: PropTypes.func.isRequired, 101 | } 102 | 103 | export default withURLSearchParams( Layout ); 104 | -------------------------------------------------------------------------------- /src/api/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Login from 'components/Login'; 3 | 4 | const DASHBOARD_URL = process.env.NODE_ENV == 'development' ? 'http://localhost:3001' : 'https://dashboard-backend.qz.ai'; 5 | 6 | const AuthContext = React.createContext(); 7 | 8 | class API extends React.Component { 9 | constructor( props ) { 10 | super( props ); 11 | this.baseURL = DASHBOARD_URL; 12 | const existingUser = document.cookie.split( ';' ).find( val => val.includes( 'cred' ) ); 13 | let cred = ''; 14 | if ( existingUser ) { 15 | [ , cred ] = existingUser.split( '=' ); 16 | } 17 | this.state = { 18 | cred, 19 | user: '', 20 | pass: '', 21 | loading: false, 22 | loggedIn: !!existingUser, 23 | }; 24 | } 25 | 26 | setLogin = async () => { 27 | const { user, pass } = this.state; 28 | const cred = btoa( `${user}:${pass}` ); 29 | await this.setState( { cred, loading: true } ); 30 | const res = await this.getTopics(); 31 | if ( !res.error || ( res.error && !res.error === 'Invalid Email or password.' ) ) { 32 | this.setState( { loggedIn: true, loading: false } ); 33 | document.cookie = `cred=${cred}`; 34 | } else { 35 | this.setState( { loading: false } ); 36 | } 37 | } 38 | 39 | async get( url ) { 40 | const { cred } = this.state; 41 | const res = await fetch( url, { 42 | method: 'GET', 43 | headers: { 44 | // TODO - plug in actual auth 45 | Authorization: `Basic ${cred}`, 46 | 'Content-Type': 'application/json', 47 | }, 48 | } ); 49 | const data = res.json(); 50 | return data; 51 | } 52 | 53 | getAd = ( adId ) => this.get( `${this.baseURL}/ads_by_text/${adId}` ); 54 | 55 | getAdvertiserByName = ( name ) => this.get( `${this.baseURL}/pages_by_name/${encodeURIComponent( name )}.json` ); 56 | 57 | getPayerByName = ( name ) => this.get( `${this.baseURL}/payers_by_name/${encodeURIComponent( name )}.json` ); 58 | 59 | getAdByTextHash = ( text_hash ) => this.get( `${this.baseURL}/ads_by_text/${encodeURIComponent( text_hash )}.json` ); 60 | 61 | getTopics = () => this.get( `${this.baseURL}/topics.json` ); 62 | 63 | handleChange = ( key ) => ( _, { value } ) => this.setState( { [key]: value } ); 64 | 65 | search = ( params = {} ) => { 66 | const { poliprobMin = 70, poliprobMax = 100 } = params; 67 | const parsedParams = Object.keys( params ).map( param => `${param}=${params[param].join( ',' )}` ).join( '&' ); 68 | return this.get( `${this.baseURL}/ads/search.json?${parsedParams}&poliprob=[${poliprobMin},${poliprobMax}]` ); 69 | } 70 | 71 | render() { 72 | const { loading, loggedIn } = this.state; 73 | const baseProps = { 74 | getAd: this.getAd, 75 | getAdvertiserByName: this.getAdvertiserByName, 76 | getPayerByName: this.getPayerByName, 77 | getAdByTextHash: this.getAdByTextHash, 78 | getTopics: this.getTopics, 79 | search: this.search, 80 | }; 81 | 82 | if ( !loggedIn ) { 83 | return ( 84 | 89 | ); 90 | } 91 | 92 | return ( 93 | 94 | { 95 | this.props.children 96 | } 97 | 98 | ); 99 | } 100 | }; 101 | 102 | export const withAPI = WrappedComponent => () => ( 103 | 104 | { 105 | props => 106 | } 107 | 108 | ); 109 | 110 | export default API; 111 | -------------------------------------------------------------------------------- /src/components/Targets/index.jsx: -------------------------------------------------------------------------------- 1 | import React, { Fragment } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { withURLSearchParams } from 'utils'; 4 | import { Button, Divider, Icon, Label } from 'semantic-ui-react'; 5 | import classnames from 'classnames/bind'; 6 | import styles from './Targets.module.css'; 7 | 8 | const cx = classnames.bind( styles ); 9 | 10 | export const TargetFilters = ( { getParam } ) => { 11 | const targets = JSON.parse( getParam( 'targeting' ) ); 12 | const formattedTargets = []; 13 | 14 | if ( !targets || !targets.length ) { 15 | return null; 16 | } 17 | 18 | for ( const targetParam of targets ) { 19 | const [ target, segment ] = targetParam; 20 | formattedTargets.push( { target, segment } ); 21 | } 22 | 23 | return ( 24 | 25 | 26 |
27 |

Applied Targets:

28 | 29 |
30 |
31 | ); 32 | }; 33 | 34 | TargetFilters.propTypes = { 35 | getParam: PropTypes.func.isRequired, 36 | }; 37 | 38 | const TargetButton = ( { isPresent, target, targetSearch, inAd } ) => { 39 | const { target: type, segment, count } = target; 40 | return ( 41 |
42 |
43 | 44 | { 45 | isPresent 46 | ? ( 47 | 50 | ) : null 51 | } 52 | 59 | 60 | { 61 | target.segment 62 | ? ( 63 | 66 | ) : null 67 | } 68 | 69 |
70 |
71 | ); 72 | }; 73 | 74 | const Targets = ( { 75 | getParam, 76 | setParam, 77 | targets, 78 | inAd 79 | } ) => { 80 | const parsedTargets = JSON.parse( getParam( 'targeting' ) ) || []; 81 | const formattedParsedTargets = parsedTargets.map( toFormat => ( { target: toFormat[0], segment: toFormat[1] } ) ); 82 | 83 | const targetSearch = ( isPresent, type, segment ) => () => { 84 | let newTargets; 85 | if ( isPresent ) { 86 | // remove if we already have this target 87 | newTargets = parsedTargets.filter( parsedTarget => parsedTarget[1] ? ( parsedTarget[0] !== type || parsedTarget[1] !== segment ) : parsedTarget[0] !== type ); 88 | } else { 89 | // otherwise add new target to list and push to history 90 | newTargets = parsedTargets.concat( [ [ type, segment ] ] ); 91 | } 92 | setParam( 'targeting', newTargets.length ? JSON.stringify( newTargets ) : '' ); 93 | }; 94 | 95 | return ( 96 |
97 | { 98 | targets.map( ( target, idx ) => { 99 | const isPresent = formattedParsedTargets.some( item => target.target === item.target && target.segment === item.segment ); 100 | return ( 101 | 102 | ); 103 | } ) 104 | } 105 |
106 | ); 107 | }; 108 | 109 | Targets.propTypes = { 110 | getParam: PropTypes.func.isRequired, 111 | setParam: PropTypes.func.isRequired, 112 | targets: PropTypes.array.isRequired, 113 | }; 114 | 115 | const WrappedTargets = withURLSearchParams( Targets ); 116 | 117 | export default WrappedTargets; 118 | -------------------------------------------------------------------------------- /src/components/Ad/AdDetails/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { Link } from 'react-router-dom'; 4 | import { Button, Modal } from 'semantic-ui-react'; 5 | import classnames from 'classnames/bind'; 6 | import styles from './AdDetails.module.css'; 7 | 8 | const cx = classnames.bind( styles ); 9 | 10 | // export const CreativeAd = ( { html } ) =>
; 11 | 12 | export class CreativeAd extends React.Component { 13 | constructor(props) { 14 | super(props); 15 | this.adRef = React.createRef(); 16 | } 17 | componentDidMount() { 18 | if (!this.adRef || !this.adRef.current) return; 19 | const link = this.adRef.current.querySelector(".see_more_link"); 20 | if (!link) return; 21 | link.addEventListener("click", (event) => { 22 | event.preventDefault(); 23 | this.adRef.current.querySelector(".text_exposed_hide").style.display = 24 | "none"; 25 | this.adRef.current.querySelector(".see_more_link").style.display = "none"; 26 | this.adRef.current 27 | .querySelectorAll(".text_exposed_show") 28 | .forEach(node => (node.style.display = "inline")); 29 | }); 30 | } 31 | 32 | render(){ 33 | return
; 34 | } 35 | } 36 | 37 | const AdDetails = ( { ad, creativeAd, text } ) => { 38 | const { currency } = ad ? ad.ads.find( subAd => !subAd.id ) : { currency: 'USD' }; // find the FBPAC version of the ad which contains more price data 39 | const { 40 | advertiser, 41 | created_at, 42 | impressions, 43 | paid_for_by, 44 | updated_at, 45 | html, 46 | text_hash, 47 | ad_creative_link_caption, 48 | ad_creative_link_title, 49 | ad_creative_link_description 50 | } = creativeAd; 51 | 52 | const createdAt = new Date( created_at ); 53 | const updatedAt = new Date( updated_at ); 54 | 55 | return ( 56 |
59 |
60 |

{advertiser}

61 |

Paid for by: {paid_for_by || 'Unknown'}

62 |

{text}

63 |

{ad_creative_link_caption}

64 |

{ad_creative_link_title}

65 |

{ad_creative_link_description}

66 | 67 | { 68 | impressions 69 | ? ( 70 |

71 | {`${currency}`}{`${impressions} ${impressions > 1 ? 'impressions' : 'impression'}`} 72 |

73 | ) : null 74 | } 75 |

76 | First seen: {`${createdAt.toLocaleDateString( 'en-US', { dateStyle: 'full', timeStyle: 'long' } )}`} 77 |

78 | { 79 | updated_at 80 | ? ( 81 |

82 | Last updated: {`${updatedAt.toLocaleDateString( 'en-US', { dateStyle: 'full', timeStyle: 'long' } )}`} 83 |

84 | ) : null 85 | } 86 | {/* Ad Details} 90 | style={{ 91 | minHeight: '80vh', 92 | }} 93 | > 94 |
95 | 96 |
97 |

Placeholder content (awaiting further ad data)

98 |
99 |
100 |
101 | */} 102 | Ad Details 103 |
104 |
105 | ); 106 | }; 107 | 108 | AdDetails.propTypes = { 109 | ad: PropTypes.object, 110 | creativeAd: PropTypes.object, 111 | }; 112 | 113 | export default AdDetails; 114 | -------------------------------------------------------------------------------- /src/components/AdSearch/index.jsx: -------------------------------------------------------------------------------- 1 | import React, { Fragment, useEffect, useState } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { Loader, Pagination } from 'semantic-ui-react'; 4 | import { useLocation, useParams } from 'react-router-dom'; 5 | import { compose, withURLSearchParams } from 'utils'; 6 | import classnames from 'classnames/bind'; 7 | import styles from './AdSearch.module.css'; 8 | import AdWrapper from 'components/AdWrapper'; 9 | import { withAPI } from 'api'; 10 | 11 | const cx = classnames.bind( styles ); 12 | 13 | const useQuery = ( pathname ) => { 14 | const params = useParams(); 15 | const { search } = useLocation(); 16 | const searchParams = {}; 17 | 18 | const toParse = new URLSearchParams( search ); 19 | const keys = toParse.keys(); 20 | for ( const key of keys ) { 21 | searchParams[key] = toParse.get( key ).split( ',' ); 22 | } 23 | 24 | if ( pathname === '/search' ) { 25 | // don't do anything special for searches. 26 | } 27 | else if ( pathname.includes( '/advertiser' ) ) { 28 | const { advertiser } = params; 29 | searchParams["advertisers"] = [ JSON.stringify([advertiser]) ]; /* for now, advertisers can be multiple (hence JSON array) and paid for by takes only one.*/ 30 | } 31 | else if ( pathname.includes( '/payer' ) ) { 32 | const { payer } = params; 33 | searchParams["paid_for_by"] = [ payer ]; /* for now, advertisers can be multiple (hence JSON array) and paid for by takes only one. */ 34 | } else { 35 | // do nothing. 36 | } 37 | return searchParams; 38 | }; 39 | 40 | const AdMeta = ( { pages, page, setPage } ) => ( 41 |
42 | 43 |
44 | ); 45 | 46 | AdMeta.propTypes = { 47 | page: PropTypes.oneOfType( [ PropTypes.string, PropTypes.number ] ), 48 | pages: PropTypes.number.isRequired, 49 | setPage: PropTypes.func.isRequired, 50 | }; 51 | 52 | const AdSearch = ( { search: apiSearch, location: { pathname, search }, setParam } ) => { 53 | const [ adData, setAdData ] = useState( { n_pages: 0, page: 1, total_ads: 0, ads: [] } ); 54 | const [ error, setError ] = useState( '' ); 55 | const [ loading, setLoading ] = useState( true ); 56 | const query = useQuery( pathname ); 57 | 58 | useEffect( () => { 59 | const getLatestAds = async () => { 60 | setError( '' ); 61 | setLoading( true ); 62 | const data = await apiSearch( query ); 63 | if ( data.error ) { 64 | setError( data.error ); 65 | setLoading( false ); 66 | return; 67 | } 68 | setAdData( data ); 69 | setLoading( false ); 70 | }; 71 | getLatestAds(); 72 | }, [ apiSearch, pathname, search ] ); 73 | 74 | const setPage = ( _, data ) => setParam( 'page', data.activePage ); 75 | 76 | if ( error ) { 77 | return ( 78 | 79 |

Sorry, something went wrong.

80 |

{error}

81 |
82 | ); 83 | } 84 | 85 | return ( 86 | 87 | 92 |
93 | { 94 | loading 95 | ? ( 96 |
97 | 98 |
99 | ) : ( 100 | 101 | ) 102 | } 103 |
104 | { 105 | adData.ads.length > 10 && !loading 106 | ? ( 107 | 112 | ) : null 113 | } 114 |
115 | ); 116 | }; 117 | 118 | AdSearch.propTypes = { 119 | location: PropTypes.shape( { 120 | pathname: PropTypes.string.isRequired, 121 | search: PropTypes.string, 122 | } ), 123 | search: PropTypes.func.isRequired, 124 | setParam: PropTypes.func.isRequired, 125 | }; 126 | 127 | export default compose( withAPI, withURLSearchParams )( AdSearch ); 128 | -------------------------------------------------------------------------------- /src/components/Advertiser/index.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { Link, useParams } from 'react-router-dom'; 4 | import { Progress } from 'semantic-ui-react'; 5 | import classnames from 'classnames/bind'; 6 | import AdSearch from 'components/AdSearch'; 7 | import Targets from 'components/Targets'; 8 | import styles from './Advertiser.module.css'; 9 | import { withAPI } from 'api'; 10 | 11 | const cx = classnames.bind( styles ); 12 | 13 | const COLORS = [ 'orange', 'yellow', 'olive', 'green', 'teal', 'blue', 'violet', 'purple', 'pink', 'brown', 'grey' ]; 14 | 15 | const findColor = ( idx ) => { 16 | if ( COLORS[idx] ) { 17 | return COLORS[idx]; 18 | } 19 | return findColor( idx - COLORS.length ); 20 | }; 21 | 22 | const Advertiser = ( { getAdvertiserByName } ) => { 23 | const [ advertiserData, setAdvertiserData ] = useState( null ); 24 | const { advertiser } = useParams(); 25 | 26 | useEffect( () => { 27 | const getAdvertiserData = async () => { 28 | const data = await getAdvertiserByName( advertiser ); 29 | setAdvertiserData( data ); 30 | }; 31 | getAdvertiserData(); 32 | }, [ advertiser, getAdvertiserByName ] ); 33 | 34 | if ( !advertiserData ) { 35 | return ( 36 |
37 |
38 |

{advertiser}

39 |
40 |
41 | ); 42 | } 43 | 44 | const { 45 | ads, 46 | fbpac_ads, 47 | payers, 48 | precise_spend, 49 | topics, 50 | targetings, 51 | page_id 52 | } = advertiserData; 53 | 54 | return ( 55 |
56 |
57 |

{advertiser}

58 |
59 |
{precise_spend ? `$${precise_spend.toString().replace( /(\d)(?=(\d{3})+(?!\d))/g, '$1,' )} spent` : 'Unknown spend'}
60 |
{ads || 0} Facebook API ads
61 |
{fbpac_ads || 0} FBPAC ads
62 |
63 |
64 |

Topic Coverage

65 | { 66 | topics && Object.keys( topics ).sort((a, b) => topics[b] - topics[a]).map( ( topicKey, idx ) => { 67 | const targetPercent = topics[topicKey]; 68 | const color = findColor( idx ); 69 | return ( 70 |
71 |

72 | {topicKey}{Math.round( targetPercent * 100 )}% 73 |

74 | 75 |
76 | ); 77 | } ) 78 | } 79 |
80 |
81 |

Paid for by disclaimers used by this page

82 |

83 | { 84 | payers && 85 | payers 86 | .map( payer => {payer.name} ) 87 | .reduce( ( accum, payer, idx ) => { 88 | // add commas 89 | const next = [ payer, ( , ) ]; 90 | if ( idx === payers.length - 1 ) { 91 | next.pop(); 92 | } 93 | return accum.concat( next ); 94 | }, [] ) 95 | } 96 |

97 |
98 | { 99 | targetings && targetings.individual_methods && targetings.individual_methods.length > 0 && ( 100 |
101 | 102 | Targeting methods used 103 | 104 | 105 |
106 | ) 107 | } 108 |
109 | 110 |
111 | ); 112 | }; 113 | 114 | Advertiser.propTypes = { 115 | getAdvertiserByName: PropTypes.func.isRequired, 116 | }; 117 | 118 | export default withAPI( Advertiser ); 119 | -------------------------------------------------------------------------------- /src/components/Payer/index.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { Link, useParams } from 'react-router-dom'; 4 | import { Progress } from 'semantic-ui-react'; 5 | import classnames from 'classnames/bind'; 6 | import AdSearch from 'components/AdSearch'; 7 | import Targets from 'components/Targets'; 8 | import styles from './Payer.module.css'; 9 | import { withAPI } from 'api'; 10 | 11 | const cx = classnames.bind( styles ); 12 | 13 | const COLORS = [ 'orange', 'yellow', 'olive', 'green', 'teal', 'blue', 'violet', 'purple', 'pink', 'brown', 'grey' ]; 14 | 15 | const findColor = ( idx ) => { 16 | if ( COLORS[idx] ) { 17 | return COLORS[idx]; 18 | } 19 | return findColor( idx - COLORS.length ); 20 | }; 21 | 22 | const Payer = ( { getPayerByName } ) => { 23 | const [ payerData, setPayerData ] = useState( null ); 24 | const { payer } = useParams(); 25 | 26 | useEffect( () => { 27 | const getPayerData = async () => { 28 | const data = await getPayerByName( payer ); 29 | setPayerData( data ); 30 | }; 31 | getPayerData(); 32 | }, [ payer, getPayerByName ] ); 33 | 34 | if ( !payerData ) { 35 | return ( 36 |
37 |
38 |

{payer}

39 |
40 |
41 | ); 42 | } 43 | 44 | const { 45 | ads, 46 | fbpac_ads, 47 | advertisers, 48 | precise_spend, 49 | topics, 50 | targetings, 51 | } = payerData; 52 | 53 | return ( 54 |
55 |
56 |

{payer}

57 |
58 |
{precise_spend ? `$${precise_spend.toString().replace( /(\d)(?=(\d{3})+(?!\d))/g, '$1,' )} spent` : 'Unknown spend'}
59 |
{ads || 0} Facebook API ads
60 |
{fbpac_ads || 0} FBPAC ads
61 |
62 |
63 |

Topic Coverage

64 | { 65 | topics && Object.keys( topics ).map( ( topicKey, idx ) => { 66 | const targetPercent = topics[topicKey]; 67 | const color = findColor( idx ); 68 | return ( 69 |
70 |

71 | {topicKey}{Math.round( targetPercent * 100 )}% 72 |

73 | 74 |
75 | ); 76 | } ) 77 | } 78 |
79 |
80 |

Pages used by payer

81 |

82 | { 83 | advertisers && 84 | advertisers 85 | .map( page => {page.page_name} ) 86 | .reduce( ( accum, page, idx ) => { 87 | // add commas 88 | const next = [ page, ( , ) ]; 89 | if ( idx === advertisers.length - 1 ) { 90 | next.pop(); 91 | } 92 | return accum.concat( next ); 93 | }, [] ) 94 | } 95 |

96 |
97 | { 98 | targetings && targetings.individual_methods && targetings.individual_methods.length > 0 && ( 99 |
100 | 101 | Targeting Methods Used 102 | 103 | 104 |
105 | ) 106 | } 107 |
108 | 109 |
110 | ); 111 | }; 112 | 113 | Payer.propTypes = { 114 | getPayerByName: PropTypes.func.isRequired, 115 | }; 116 | 117 | export default withAPI( Payer ); 118 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # Political Ad Dashboard 4 | 5 | This repo is meant to house the dashboard for accessing data from Quartz's political ad database, which is itself a combination of data from The Globe & Mail's Facebook Political Ad Tracker (see https://fbads.theglobeandmail.com/facebook-ads/admin) and a database originally created by NYU but now maintained by Quartz. 6 | 7 | The dashboard is a React-based client-side app designed to facilitate access to the combined database. The backend/API has been created by Jeremy Merrill, and lives on a Rails app at http://dashboard.qz.ai/ . 8 | 9 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 10 | 11 | ## Running and deploying the app. 12 | 13 | Dev: Install dependencies with `npm install` and run with `npm start`. 14 | 15 | Deploy: Run `npm build`. This will output all the files required for deployment in the `/build` folder. Delete the contents of the s3 bucket `pol-ad-dashboard` and copy the contents of the `/build` folder to the s3 bucket. 16 | 17 | ## App overview 18 | 19 | The Political Ad Dashboard runs fundamentally off the URL route & params; it searches and filters in response to changes to params and should search correctly when linking urls with full params. As the route & params change, `react-router-dom`'s `BrowserRouter` listens to and broadcasts changes to the path across the app. Broadcasted changes are then parsed within either the `AdSearch` or `Advertiser` components, which then triggers a search. 20 | 21 | In order to change URL params, the most efficient way is to wrap a component in the `withURLParams` higher-order component and use the `setParam`/`toggleParam` methods. 22 | 23 | # Create React App Documentation 24 | 25 | ### Available Scripts 26 | 27 | In the project directory, you can run: 28 | 29 | #### `npm start` 30 | 31 | Runs the app in the development mode.
32 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 33 | 34 | The page will reload if you make edits.
35 | You will also see any lint errors in the console. 36 | 37 | #### `npm test` 38 | 39 | Launches the test runner in the interactive watch mode.
40 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 41 | 42 | #### `npm run build` 43 | 44 | Builds the app for production to the `build` folder.
45 | It correctly bundles React in production mode and optimizes the build for the best performance. 46 | 47 | The build is minified and the filenames include the hashes.
48 | Your app is ready to be deployed! 49 | 50 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 51 | 52 | #### `npm run eject` 53 | 54 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!** 55 | 56 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. 57 | 58 | Instead, it will copy all the configuration files and the transitive dependencies (Webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. 59 | 60 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. 61 | 62 | ### Learn More 63 | 64 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). 65 | 66 | To learn React, check out the [React documentation](https://reactjs.org/). 67 | 68 | #### Code Splitting 69 | 70 | This section has moved here: https://facebook.github.io/create-react-app/docs/code-splitting 71 | 72 | #### Analyzing the Bundle Size 73 | 74 | This section has moved here: https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size 75 | 76 | #### Making a Progressive Web App 77 | 78 | This section has moved here: https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app 79 | 80 | #### Advanced Configuration 81 | 82 | This section has moved here: https://facebook.github.io/create-react-app/docs/advanced-configuration 83 | 84 | #### Deployment 85 | 86 | This section has moved here: https://facebook.github.io/create-react-app/docs/deployment 87 | 88 | #### `npm run build` fails to minify 89 | 90 | This section has moved here: https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify 91 | -------------------------------------------------------------------------------- /src/serviceWorker.js: -------------------------------------------------------------------------------- 1 | // This optional code is used to register a service worker. 2 | // register() is not called by default. 3 | 4 | // This lets the app load faster on subsequent visits in production, and gives 5 | // it offline capabilities. However, it also means that developers (and users) 6 | // will only see deployed updates on subsequent visits to a page, after all the 7 | // existing tabs open on the page have been closed, since previously cached 8 | // resources are updated in the background. 9 | 10 | // To learn more about the benefits of this model and instructions on how to 11 | // opt-in, read https://bit.ly/CRA-PWA 12 | 13 | const isLocalhost = Boolean( 14 | window.location.hostname === 'localhost' || 15 | // [::1] is the IPv6 localhost address. 16 | window.location.hostname === '[::1]' || 17 | // 127.0.0.0/8 are considered localhost for IPv4. 18 | window.location.hostname.match( 19 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ 20 | ) 21 | ); 22 | 23 | export function register(config) { 24 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { 25 | // The URL constructor is available in all browsers that support SW. 26 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href); 27 | if (publicUrl.origin !== window.location.origin) { 28 | // Our service worker won't work if PUBLIC_URL is on a different origin 29 | // from what our page is served on. This might happen if a CDN is used to 30 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374 31 | return; 32 | } 33 | 34 | window.addEventListener('load', () => { 35 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 36 | 37 | if (isLocalhost) { 38 | // This is running on localhost. Let's check if a service worker still exists or not. 39 | checkValidServiceWorker(swUrl, config); 40 | 41 | // Add some additional logging to localhost, pointing developers to the 42 | // service worker/PWA documentation. 43 | navigator.serviceWorker.ready.then(() => { 44 | console.log( 45 | 'This web app is being served cache-first by a service ' + 46 | 'worker. To learn more, visit https://bit.ly/CRA-PWA' 47 | ); 48 | }); 49 | } else { 50 | // Is not localhost. Just register service worker 51 | registerValidSW(swUrl, config); 52 | } 53 | }); 54 | } 55 | } 56 | 57 | function registerValidSW(swUrl, config) { 58 | navigator.serviceWorker 59 | .register(swUrl) 60 | .then(registration => { 61 | registration.onupdatefound = () => { 62 | const installingWorker = registration.installing; 63 | if (installingWorker == null) { 64 | return; 65 | } 66 | installingWorker.onstatechange = () => { 67 | if (installingWorker.state === 'installed') { 68 | if (navigator.serviceWorker.controller) { 69 | // At this point, the updated precached content has been fetched, 70 | // but the previous service worker will still serve the older 71 | // content until all client tabs are closed. 72 | console.log( 73 | 'New content is available and will be used when all ' + 74 | 'tabs for this page are closed. See https://bit.ly/CRA-PWA.' 75 | ); 76 | 77 | // Execute callback 78 | if (config && config.onUpdate) { 79 | config.onUpdate(registration); 80 | } 81 | } else { 82 | // At this point, everything has been precached. 83 | // It's the perfect time to display a 84 | // "Content is cached for offline use." message. 85 | console.log('Content is cached for offline use.'); 86 | 87 | // Execute callback 88 | if (config && config.onSuccess) { 89 | config.onSuccess(registration); 90 | } 91 | } 92 | } 93 | }; 94 | }; 95 | }) 96 | .catch(error => { 97 | console.error('Error during service worker registration:', error); 98 | }); 99 | } 100 | 101 | function checkValidServiceWorker(swUrl, config) { 102 | // Check if the service worker can be found. If it can't reload the page. 103 | fetch(swUrl, { 104 | headers: { 'Service-Worker': 'script' } 105 | }) 106 | .then(response => { 107 | // Ensure service worker exists, and that we really are getting a JS file. 108 | const contentType = response.headers.get('content-type'); 109 | if ( 110 | response.status === 404 || 111 | (contentType != null && contentType.indexOf('javascript') === -1) 112 | ) { 113 | // No service worker found. Probably a different app. Reload the page. 114 | navigator.serviceWorker.ready.then(registration => { 115 | registration.unregister().then(() => { 116 | window.location.reload(); 117 | }); 118 | }); 119 | } else { 120 | // Service worker found. Proceed as normal. 121 | registerValidSW(swUrl, config); 122 | } 123 | }) 124 | .catch(() => { 125 | console.log( 126 | 'No internet connection found. App is running in offline mode.' 127 | ); 128 | }); 129 | } 130 | 131 | export function unregister() { 132 | if ('serviceWorker' in navigator) { 133 | navigator.serviceWorker.ready.then(registration => { 134 | registration.unregister(); 135 | }); 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /src/components/constants.js: -------------------------------------------------------------------------------- 1 | export const COMMON_TARGETS = [ 2 | { target: 'MinAge', segment: '18' }, 3 | { target: 'Age', segment: '18 and older' }, 4 | { target: 'Region', segment: 'the United States' }, 5 | { target: 'Retargeting', segment: 'people who may be similar to their customers' }, 6 | { target: 'List', segment: '' }, 7 | { target: 'Like', segment: '' }, 8 | { target: 'Website', segment: 'people who have visited their website or used one of their apps' }, 9 | { target: 'Activity on the Facebook Family', segment: '' }, 10 | { target: 'Segment', segment: 'US politics (very liberal)' }, 11 | { target: 'MinAge', segment: '25' }, 12 | { target: 'MinAge', segment: '35' }, 13 | { target: 'Gender', segment: 'women' }, 14 | { target: 'Age', segment: '25 and older' }, 15 | { target: 'Age', segment: '35 and older' }, 16 | { target: 'MinAge', segment: '30' }, 17 | { target: 'Age', segment: '30 and older' }, 18 | { target: 'MinAge', segment: '21' }, 19 | { target: 'Age', segment: '21 and older' }, 20 | { target: 'State', segment: 'California' }, 21 | { target: 'Region', segment: 'California' }, 22 | { target: 'MinAge', segment: '45' }, 23 | { target: 'Region', segment: 'Florida' }, 24 | { target: 'Age', segment: '45 and older' }, 25 | { target: 'Region', segment: 'Texas' }, 26 | { target: 'MinAge', segment: '40' }, 27 | { target: 'Age', segment: '40 and older' }, 28 | { target: 'Segment', segment: 'US politics (liberal)' }, 29 | { target: 'Gender', segment: 'men' }, 30 | { target: 'MinAge', segment: '50' }, 31 | { target: 'Interest', segment: 'Democratic Party (United States)' }, 32 | { target: 'Age', segment: '50 and older' }, 33 | { target: 'State', segment: 'New York' }, 34 | { target: 'State', segment: 'Texas' }, 35 | { target: 'MinAge', segment: '55' }, 36 | { target: 'State', segment: 'Florida' }, 37 | { target: 'Interest', segment: 'Barack Obama' }, 38 | { target: 'Region', segment: 'Michigan' }, 39 | { target: 'Region', segment: 'Washington' }, 40 | { target: 'Age', segment: '55 and older' }, 41 | { target: 'Region', segment: 'Colorado' }, 42 | { target: 'Segment', segment: 'US politics (moderate)' }, 43 | { target: 'Region', segment: 'New York' }, 44 | { target: 'Interest', segment: 'Bernie Sanders' }, 45 | { target: 'Region', segment: 'Ohio' }, 46 | { target: 'Region', segment: 'Massachusetts' }, 47 | { target: 'Region', segment: 'Minnesota' }, 48 | { target: 'Retargeting', segment: 'recently near their business' }, 49 | { target: 'State', segment: 'Washington' }, 50 | { target: 'Segment', segment: 'US politics (very conservative)' }, 51 | { target: 'Region', segment: 'Arizona' }, 52 | { target: 'State', segment: 'Minnesota' }, 53 | { target: 'State', segment: 'Illinois' }, 54 | { target: 'Age', segment: '13 and older' }, 55 | { target: 'MinAge', segment: '24' }, 56 | { target: 'MinAge', segment: '13' }, 57 | { target: 'State', segment: 'Michigan' }, 58 | { target: 'City', segment: 'New York' }, 59 | { target: 'Region', segment: 'Wisconsin' }, 60 | { target: 'State', segment: 'Pennsylvania' }, 61 | { target: 'Region', segment: 'Missouri' }, 62 | { target: 'City', segment: 'Washington' }, 63 | { target: 'Region', segment: 'North Carolina' }, 64 | { target: 'Interest', segment: 'Donald Trump' }, 65 | { target: 'Region', segment: 'Pennsylvania' }, 66 | { target: 'Age', segment: '24 and older' }, 67 | { target: 'MaxAge', segment: '54' }, 68 | { target: 'MaxAge', segment: '49' }, 69 | { target: 'Segment', segment: 'Member of a family-based household.' }, 70 | { target: 'MinAge', segment: '22' }, 71 | { target: 'State', segment: 'Virginia' }, 72 | { target: 'Interest', segment: 'Planned Parenthood' }, 73 | { target: 'Region', segment: 'Georgia' }, 74 | { target: 'Region', segment: 'Nevada' }, 75 | { target: 'Segment', segment: 'US politics (conservative)' }, 76 | { target: 'Age', segment: '22 and older' }, 77 | { target: 'State', segment: 'District of Columbia' }, 78 | { target: 'Interest', segment: 'Politics and social issues' }, 79 | { target: 'State', segment: 'Maryland' }, 80 | { target: 'Region', segment: 'Illinois' }, 81 | { target: 'MaxAge', segment: '64' }, 82 | { target: 'Region', segment: 'North Dakota' }, 83 | { target: 'State', segment: 'North Carolina' }, 84 | { target: 'State', segment: 'Missouri' }, 85 | { target: 'State', segment: 'Ohio' }, 86 | { target: 'Region', segment: 'Oregon' }, 87 | { target: 'Region', segment: 'Montana' }, 88 | { target: 'State', segment: 'Arizona' }, 89 | { target: 'Segment', segment: 'Likely to engage with political content (liberal)' }, 90 | { target: 'Age', segment: '65' }, 91 | { target: 'MinAge', segment: '65' }, 92 | { target: 'MaxAge', segment: '65' }, 93 | { target: 'Language', segment: 'English (US)' }, 94 | { target: 'Interest', segment: 'Politics' }, 95 | { target: 'Interest', segment: 'Republican Party (United States)' }, 96 | { target: 'MaxAge', segment: '55' }, 97 | { target: 'Segment', segment: 'Multicultural affinity: African American (US).' }, 98 | { target: 'Region', segment: 'Iowa' }, 99 | { target: 'MinAge', segment: '20' }, 100 | { target: 'MaxAge', segment: '34' }, 101 | { target: 'State', segment: 'Oregon' }, 102 | ]; 103 | 104 | export const COMMON_TARGETS_GROUPED = { 105 | MinAge: [ 106 | '13', 107 | '18', 108 | '20', 109 | '21', 110 | '22', 111 | '24', 112 | '25', 113 | '30', 114 | '35', 115 | '40', 116 | '45', 117 | '50', 118 | '55', 119 | '59', 120 | '65' ], 121 | Region: [ 122 | 'the United States', 123 | 'California', 124 | 'Florida', 125 | 'Texas', 126 | 'Michigan', 127 | 'Washington', 128 | 'Colorado', 129 | 'New York', 130 | 'Ohio', 131 | 'Massachusetts', 132 | 'Minnesota', 133 | 'Arizona', 134 | 'Wisconsin', 135 | 'Missouri', 136 | 'North Carolina', 137 | 'Pennsylvania', 138 | 'Georgia', 139 | 'Nevada', 140 | 'Illinois', 141 | 'North Dakota', 142 | 'Oregon', 143 | 'Montana', 144 | 'Iowa' ], 145 | Retargeting: [ 146 | 'people who may be similar to their customers', 147 | 'recently near their business' ], 148 | List: [ '' ], 149 | Like: [ '' ], 150 | Website: [ 'people who have visited their website or used one of their apps' ], 151 | 'Activity on the Facebook Family': [ '' ], 152 | Segment:[ 153 | 'US politics (very liberal)', 154 | 'US politics (liberal)', 155 | 'US politics (moderate)', 156 | 'US politics (conservative)', 157 | 'US politics (very conservative)', 158 | 'Member of a family-based household.', 159 | 'Likely to engage with political content (liberal)', 160 | 'Likely to engage with political content (conservative)', 161 | 'Multicultural affinity: African American (US).', 162 | 'Multicultural affinity: Asian American (US).', 163 | 'Multicultural affinity: Hispanic (US - All).', 164 | 'Multicultural affinity: Hispanic (US - English dominant).', 165 | 'Multicultural affinity: Hispanic (US - Bilingual).', 166 | 'Multicultural affinity: Hispanic (US - Spanish dominant).'], 167 | Gender: [ 'women', 'men' ], 168 | Interest: [ 169 | 'Democratic Party (United States)', 170 | 'Barack Obama', 171 | 'Bernie Sanders', 172 | 'Donald Trump', 173 | 'Planned Parenthood', 174 | 'Politics and social issues', 175 | 'Politics', 176 | 'Republican Party (United States)', 177 | 'Sean Hannity', ], 178 | Language: [ 'English (US)' ], 179 | }; 180 | -------------------------------------------------------------------------------- /src/components/Ad/fb_ad.scss: -------------------------------------------------------------------------------- 1 | // gettin' hacky: 2 | @mixin clearfix() { 3 | &::after { 4 | display: block; 5 | content: ""; 6 | clear: both; 7 | } 8 | } 9 | 10 | .scaledImageFitWidth, .img { 11 | width: 100%; 12 | } 13 | 14 | .uiList { 15 | list-style: none; 16 | padding: 0; 17 | } 18 | .uiList li:nth-of-type(1n+3) { 19 | display: none; 20 | } 21 | 22 | .pagination .item:focus { 23 | outline: none; 24 | } 25 | 26 | 27 | h5, h6 { 28 | font-size: 1rem; 29 | margin-bottom: 0; 30 | margin-top: 0; 31 | } 32 | 33 | // FB SCSS: 34 | 35 | ._4uoz { 36 | // Targeting info 37 | color: #a6a6a6; 38 | max-width: 500px; 39 | padding: 0 12px; 40 | margin: 12px auto 24px auto; 41 | a { 42 | display: none; 43 | } 44 | b { 45 | color: #333; 46 | font-weight: bold; 47 | } 48 | ._4hcd { 49 | margin-top: 6px; 50 | } 51 | } 52 | 53 | ._2lgs { 54 | /* intrusive S in Sponsored (i.e. SpSonSsOSredS or whatever) */ 55 | display: none !important; 56 | } 57 | ._34k3 { 58 | display: none !important; 59 | } 60 | 61 | ._4-i0._26c5, 62 | ._4uor._52jw, 63 | ._4uou, 64 | ._5lnf.uiOverlayFooter._5a8u._4866, 65 | ._4ubd._3tsq, 66 | .hidden_elem, 67 | ._567v._3bw, 68 | ._35sk { 69 | // Excess targeting info and video ads 70 | display: none; 71 | } 72 | 73 | ._1dwg._1w_m, 74 | .ego_unit { 75 | // Sidebar ad wrapper div 76 | background-color: white; 77 | padding: 12px; 78 | margin: 0 auto; 79 | overflow: hidden; 80 | border-radius: 4px; 81 | 82 | ._1dwg._1w_m { 83 | display: block; 84 | max-width: 100%; 85 | padding: 0; 86 | margin: 0; 87 | border: none; 88 | border-radius: 0; 89 | } 90 | } 91 | 92 | .message { 93 | margin: 12px auto 0; 94 | border-radius: 4px; 95 | min-width: 308px; 96 | max-width: 500px; 97 | 98 | p { 99 | font-size: 1em; 100 | a { 101 | word-break: break-all; 102 | } 103 | } 104 | 105 | s { 106 | text-decoration: none; 107 | } 108 | } 109 | 110 | .targetingHtml .img { 111 | width: unset; 112 | } 113 | 114 | .targeting_info { 115 | max-width: 500px; 116 | margin: 0 auto 12px; 117 | h3 { 118 | font-weight: bold; 119 | padding-left: 12px; 120 | margin-top: 6px; 121 | } 122 | 123 | .targeting { 124 | b { 125 | font-weight: bold; 126 | } 127 | } 128 | } 129 | 130 | 131 | .ad-metadata { 132 | max-width: 500px; 133 | margin: 0 auto 1em; 134 | padding: 0 12px; 135 | display: block; 136 | 137 | .permalink { 138 | float: right; 139 | } 140 | } 141 | 142 | ._1dwg._1w_m { 143 | // Timeline ad wrapper div 144 | max-width: 500px; 145 | border: 1px solid #dddfe2; 146 | } 147 | 148 | .ego_unit { 149 | // Sidebar ad wrapper div 150 | max-width: 308px; 151 | border: 1px solid #dddfe2; 152 | } 153 | 154 | /* NEWS FEED ADS */ 155 | 156 | #graphic #ads { 157 | display: flex; 158 | flex-wrap: wrap; 159 | justify-content: center; 160 | } 161 | 162 | .ad { 163 | margin: 0 20px; 164 | } 165 | 166 | ._5pb8._1yz2._8o._8s.lfloat._ohe, 167 | ._38vo { 168 | // Profile pic wrapper 169 | display: block; 170 | float: left; 171 | width: 32px; 172 | margin-right: 8px; 173 | border-radius: 16px; 174 | overflow: hidden; 175 | & img { 176 | width: 100%; 177 | } 178 | } 179 | 180 | ._5x46, 181 | ._3dp._29k { 182 | // Profile name wrapper 183 | @include clearfix; 184 | margin-bottom: 6px; 185 | a:hover, 186 | a:focus { 187 | text-decoration: underline; 188 | } 189 | } 190 | 191 | ._5pbw { 192 | // Profile name 193 | font-weight: bold; 194 | } 195 | 196 | ._5pcp._5lel._232_, 197 | ._5paw { 198 | // 'Sponsored' 199 | font-size: 12px; 200 | color: rgb(144, 148, 156); 201 | div { 202 | display: inline; 203 | } 204 | a { 205 | color: inherit; 206 | } 207 | } 208 | 209 | ._5mfr .img { 210 | display: none; 211 | } 212 | ._5z3s .img { // pixel. _m54 _5z3s _1jto _3htz 213 | display: none !important; 214 | } 215 | ._27vv { 216 | display: none; 217 | } 218 | .lock.img { 219 | // 'Public' globe 220 | display: inline-block; 221 | width: 12px; 222 | height: 12px; 223 | // background-image: url(../images/fb-globe.png); 224 | background-repeat: no-repeat; 225 | background-size: 100%; 226 | vertical-align: -2px; 227 | } 228 | 229 | .userContent { 230 | // Post text 231 | clear: both; 232 | } 233 | // emojis 234 | .ad .message { 235 | .userContent img, 236 | .mbs._6m6 img, 237 | ._6a img { 238 | display: none; 239 | } 240 | 241 | img._51mq { 242 | // inline emoji in the status headline, e.g. http://localhost:8080/facebook-ads/admin/ads/6089753382223 243 | display: inline; 244 | width: unset; 245 | } 246 | } 247 | 248 | .text_exposed_show { 249 | // Truncated post text 250 | display: none; 251 | } 252 | .text_exposed_hide { 253 | display: inline; 254 | } 255 | .see_more_link{ 256 | cursor: pointer; 257 | } 258 | 259 | .mtm, 260 | ._dcs { 261 | // Article wrap 262 | margin-top: 10px; 263 | border: 1px solid rgba(0, 0, 0, 0.1); 264 | .mtm, 265 | ._dcs { 266 | margin-top: 0; 267 | border: none; 268 | } 269 | } 270 | 271 | ._150c { 272 | // Video (and slideshow?) wrap 273 | > * { 274 | display: none; 275 | } 276 | > img:first-of-type { 277 | display: block; 278 | width: 100%; 279 | } 280 | } 281 | 282 | ._2a2q ._5dec._xcx { 283 | // Slideshow images 284 | display: none; 285 | &:first-child { 286 | display: block; 287 | } 288 | } 289 | 290 | ._6ks > a { 291 | // Article image wrap 292 | display: block; 293 | } 294 | 295 | .fbStoryAttachmentImage { 296 | // Article image 297 | } 298 | 299 | ._6m3._--6, 300 | ._3ekx._29_4 { 301 | // Article description wrap 302 | padding: 12px; 303 | border-top: 1px solid rgba(0, 0, 0, 0.1); 304 | } 305 | 306 | ._5qf- { 307 | // Profile metadata 308 | display: none; 309 | } 310 | 311 | ._5s6c { 312 | // Article hed 313 | font-family: Georgia, "Times New Roman", Times, serif; 314 | font-size: 18px; 315 | line-height: 22px; 316 | margin-bottom: 5px; 317 | a { 318 | color: inherit; 319 | } 320 | } 321 | 322 | ._6m7._3bt9, 323 | ._5q4r { 324 | // Article dek 325 | font-size: 12px; 326 | line-height: 16px; 327 | } 328 | 329 | ._59tj._2iau, 330 | ._1s4d { 331 | // Article URL/CTA wrap 332 | vertical-align: bottom; 333 | padding-top: 5px; 334 | @include clearfix; 335 | } 336 | 337 | ._522u.rfloat._ohf, 338 | ._275-._4s-8.rfloat._ohf { 339 | // CTA button 340 | display: inline-block; 341 | float: right; 342 | margin-left: 10px; 343 | a { 344 | display: block; 345 | color: rgb(75, 79, 86); 346 | font-size: 12px; 347 | font-weight: bold; 348 | line-height: 22px; 349 | padding: 0 8px; 350 | background-color: rgb(246, 247, 249); 351 | border: 1px solid rgb(206, 208, 212); 352 | border-radius: 2px; 353 | } 354 | a:hover, 355 | a:focus { 356 | background-color: #e9ebee; 357 | } 358 | } 359 | 360 | ._6lz._6mb, 361 | ._275y { 362 | // Article URL 363 | color: rgb(144, 148, 156); 364 | font-size: 12px; 365 | line-height: 11px; 366 | text-transform: uppercase; 367 | padding-top: 13px; 368 | word-break: break-all; 369 | } 370 | 371 | ._5g-l, 372 | ._5tc6, 373 | ._1-m5 { 374 | // 'Not affiliated with Facebook' 375 | display: none; 376 | } 377 | 378 | /* SIDEBAR ADS */ 379 | 380 | .ego_unit { 381 | // Sidebar ad 382 | font-size: 13px; 383 | line-height: 16px; 384 | 385 | // These all use different class names, hence the delightful muck below. Fun! 386 | 387 | div > div > a > div > div:nth-child(2) { 388 | // Sidebar ad hed/URL wrapper 389 | padding-top: 10px; 390 | } 391 | div > div > a > div > div:nth-child(4) { 392 | // Sidebar ad article dek 393 | color: rgb(144, 148, 156); 394 | } 395 | } -------------------------------------------------------------------------------- /src/components/AdSearch/sample_ad.json: -------------------------------------------------------------------------------- 1 | { 2 | "text":"Let's get organized! Team Warren will convene a volunteer training in Charlotte on Tuesday, October 1. \n\nJoin us to learn more about how to spread Elizabeth’s vision for big, structural change.", 3 | "fbpac_ads_count":2, 4 | "api_ads_count":2, 5 | "min_spend":200, 6 | "max_spend":998, 7 | "min_impressions":20000, 8 | "max_impressions":99998, 9 | "ads":[ 10 | { 11 | "text":"Let's get organized! Team Warren will convene a volunteer training in Charlotte on Tuesday, October 1. \n\nJoin us to learn more about how to spread Elizabeth’s vision for big, structural change.", 12 | "start_date":"2019-09-21T17:27:34.000Z", 13 | "end_date":"2019-10-01T21:00:00.000Z", 14 | "creation_date":"2019-09-21T17:27:34.000Z", 15 | "page_id":38471053686, 16 | "currency":"USD", 17 | "snapshot_url":"https://www.facebook.com/ads/archive/render_ad/?id=430715657554578\u0026access_token=EAAhZBQYQVMRMBAK6KLIZCCQBkhuH8AyAJj1XoJuhy0KRU4V9gMiVe4cs4ZB6GxdetcNz8uosmAtbBzrCsEvF1YV7RPs3FssXDZCjcSnd0U4ZCYQKdEh78i2ZAtBO2oZCvOhrZBwR8Mkt7KFvtBUX2r2xMNoiW2Y295xGiZBND6Eq3zke3fo9e3MUY", 18 | "is_active":false, 19 | "ad_sponsor_id":49768, 20 | "archive_id":430715657554578, 21 | "nyu_id":5746931, 22 | "link_caption":"Charlotte Barnstorm Volunteer Training with Team Warren", 23 | "link_description":"Charlotte Barnstorm Volunteer Training with Team Warren", 24 | "link_title":"", 25 | "ad_category_id":null, 26 | "ad_id":null, 27 | "country_code":"US", 28 | "most_recent":null, 29 | "funding_entity":null, 30 | "impressions":[ 31 | 32 | ], 33 | "writable_ad":{ 34 | "id":2060, 35 | "partisanship":null, 36 | "purpose":null, 37 | "optimism":null, 38 | "attack":null, 39 | "archive_id":430715657554578, 40 | "created_at":"2020-01-24T00:02:25.832Z", 41 | "updated_at":"2020-01-24T00:02:25.832Z", 42 | "text_hash":"34bdaea8efc6ac866e3b4b95664338eb0b121830", 43 | "ad_id":null 44 | } 45 | }, 46 | { 47 | "text":"Let's get organized! Team Warren will convene a volunteer training in Charlotte on Tuesday, October 1. \n\nJoin us to learn more about how to spread Elizabeth’s vision for big, structural change.", 48 | "start_date":"2019-09-21T17:27:35.000Z", 49 | "end_date":"2019-10-01T21:00:00.000Z", 50 | "creation_date":"2019-09-21T17:27:35.000Z", 51 | "page_id":38471053686, 52 | "currency":"USD", 53 | "snapshot_url":"https://www.facebook.com/ads/archive/render_ad/?id=523796201765923\u0026access_token=EAAhZBQYQVMRMBAK6KLIZCCQBkhuH8AyAJj1XoJuhy0KRU4V9gMiVe4cs4ZB6GxdetcNz8uosmAtbBzrCsEvF1YV7RPs3FssXDZCjcSnd0U4ZCYQKdEh78i2ZAtBO2oZCvOhrZBwR8Mkt7KFvtBUX2r2xMNoiW2Y295xGiZBND6Eq3zke3fo9e3MUY", 54 | "is_active":false, 55 | "ad_sponsor_id":49768, 56 | "archive_id":523796201765923, 57 | "nyu_id":5746493, 58 | "link_caption":"Charlotte Barnstorm Volunteer Training with Team Warren", 59 | "link_description":"Charlotte Barnstorm Volunteer Training with Team Warren", 60 | "link_title":"", 61 | "ad_category_id":null, 62 | "ad_id":null, 63 | "country_code":"US", 64 | "most_recent":null, 65 | "funding_entity":null, 66 | "impressions":[ 67 | 68 | ], 69 | "writable_ad":{ 70 | "id":4157, 71 | "partisanship":null, 72 | "purpose":null, 73 | "optimism":null, 74 | "attack":null, 75 | "archive_id":523796201765923, 76 | "created_at":"2020-01-24T00:02:47.472Z", 77 | "updated_at":"2020-01-24T00:02:47.472Z", 78 | "text_hash":"34bdaea8efc6ac866e3b4b95664338eb0b121830", 79 | "ad_id":null 80 | } 81 | }, 82 | { 83 | "id":"23843761749580770", 84 | "html":"\u003cdiv class=\"_5pa- userContentWrapper\"\u003e\u003cdiv class=\"_1dwg _1w_m\"\u003e\u003cdiv\u003e\u003cdiv class=\"n_1et7rg-s22 c_1et7rh2qkn clearfix\"\u003e\u003cdiv class=\"clearfix s_1et7rg-tsr\"\u003e\u003ca class=\"_5pb8 g_1et7rh2qkg _8o _8s lfloat _ohe\" data-hovercard=\"https://www.facebook.com/38471053686\" href=\"https://www.facebook.com/ElizabethWarren/\"\u003e\u003cdiv class=\"_38vo\"\u003e\u003c!-- react-mount-point-unstable --\u003e\u003cdiv\u003e\u003cimg class=\"_s0 _4ooo _5xib _44ma _54ru img\" src=\"https://fbpac-ads-public.s3.amazonaws.com/v/t1.0-1/p32x32/62053379_10156558715523687_1252765707393826816_n.jpg\"\u003e\u003c/div\u003e\u003c/div\u003e\u003c/a\u003e\u003cdiv class=\"clearfix _42ef\"\u003e\u003cdiv class=\"rfloat _ohf\"\u003e\u003c/div\u003e\u003cdiv class=\"t_1et7rg-tsk\"\u003e\u003cdiv\u003e\u003cdiv class=\"_6a _5u5j\"\u003e\u003cdiv class=\"_6a _6b\"\u003e\u003c/div\u003e\u003cdiv class=\"_6a _5u5j _6b\"\u003e\u003ch6 class=\"_7tae _14f3 _14f5 _5pbw _5vra\"\u003e\u003cspan class=\"fwn fcg\"\u003e\u003cspan class=\"fcg\"\u003e\u003cspan class=\"fwb\"\u003e\u003ca class=\"profileLink\" data-hovercard=\"https://www.facebook.com/38471053686\" href=\"https://www.facebook.com/ElizabethWarren/\"\u003eElizabeth Warren\u003c/a\u003e\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003c/h6\u003e\u003cdiv\u003e\u003ca class=\"_5pcq\" href=\"#\" id=\"u_fetchstream_23_18\"\u003e\u003cspan\u003e\u003cspan class=\"_3nlk\"\u003eSponsored\u003c/span\u003e ⋅ Paid for by \u003cspan class=\"_3nlk\"\u003eWarren for President\u003c/span\u003e\u003c/span\u003e\u003c/a\u003e\u003cspan class=\"_6spk\"\u003e · \u003c/span\u003e\u003ca class=\"uiStreamPrivacy inlineBlock fbStreamPrivacy fbPrivacyAudienceIndicator _5pcq\" href=\"#\"\u003e\u003ci class=\"lock img sp_gNqCxYl3J40 sx_689d9c\"\u003e\u003c/i\u003e\u003c/a\u003e\u003c/div\u003e\u003c/div\u003e\u003c/div\u003e\u003c/div\u003e\u003c/div\u003e\u003c/div\u003e\u003c/div\u003e\u003c/div\u003e\u003cdiv class=\"_5pbx userContent _3576\" id=\"js_85y\"\u003e\u003cp\u003eLet's get organized! Team Warren will convene a volunteer training in Charlotte on Tuesday, October 1. \u003c/p\u003e\u003cp\u003e Join us to learn more about how to spread Elizabeth’s vision for big, structural change.\u003c/p\u003e\u003c/div\u003e\u003cdiv class=\"_3x-2\"\u003e\u003cdiv\u003e\u003cdiv class=\"mtm\"\u003e\u003cdiv class=\"_1ci8\"\u003e\u003cdiv\u003e\u003cdiv class=\"_6l4 _59ap _64lx _3eqz _1-9r _2rk1 _20pq _3eqw _3n1j\"\u003e\u003cdiv class=\"_5inf\"\u003e\u003cdiv class=\"uiScaledImageContainer _6m5 fbStoryAttachmentImage _5ind\"\u003e\u003cimg class=\"scaledImageFitWidth img\" src=\"https://fbpac-ads-public.s3.amazonaws.com/v/t45.1600-4/c0.21.1140.598a/p1140x598/68220371_23843666791720770_6870780649381298176_n.png\"\u003e\u003c/div\u003e\u003ca class=\"_5ine\" href=\"https://www.facebook.com/events/313321032835343/\"\u003e\u003c/a\u003e\u003c/div\u003e\u003cdiv class=\"_3z2 _3z3 _2v9b\"\u003e\u003cdiv\u003e\u003cdiv class=\"_3ekx _29_4 _fwr\"\u003e\u003cdiv class=\"_44ae _651x\"\u003e\u003cdiv class=\"_6m3 _--6 _4dhn _opx\"\u003e\u003cdiv class=\"_59tj _2iau _oq1\"\u003e\u003cdiv class=\"_6lz _6mb _1t62\"\u003e\u003cdiv\u003eTUE, OCT 1 AT 7 PM\u003c/div\u003e\u003c/div\u003e\u003c/div\u003e\u003cdiv class=\"_3n1k\"\u003e\u003cdiv class=\"_6m6 _2cnj\"\u003e\u003ca href=\"https://www.facebook.com/events/313321032835343/\"\u003eCharlotte Barnstorm Volunteer Training with Team Warren\u003c/a\u003e\u003c/div\u003e\u003cdiv class=\"_6m7 _3bt9 hidden_elem\"\u003e\u003cdiv class=\"fsm fwn fcg\"\u003e\u003cspan\u003eCharlotte, NC\u003c/span\u003e\u003c/div\u003e\u003c/div\u003e\u003c/div\u003e\u003cdiv class=\"_20l4 _svw _5inn\"\u003e206 people interested · 21 people going\u003c/div\u003e\u003c/div\u003e\u003cdiv class=\"_44af _2e6-\"\u003e\u003cdiv class=\"_6a _6b _fw_ _fx0\"\u003e\u003cdiv class=\"_19ab\" id=\"u_fetchstream_23_19\"\u003e\u003cspan id=\"u_fetchstream_23_1b\"\u003e\u003ca class=\"_42ft _4jy0 fbEventAttachmentCTAButton _522u _4jy3 _517h _51sy\" href=\"#\"\u003e\u003ci class=\"_3-8_ img sp_kJazzH4jvE1 sx_634b40\"\u003e\u003c/i\u003eInterested\u003c/a\u003e\u003c/span\u003e\u003c/div\u003e\u003c/div\u003e\u003c/div\u003e\u003c/div\u003e\u003c/div\u003e\u003c/div\u003e\u003c/div\u003e\u003c/div\u003e\u003c/div\u003e\u003cdiv class=\"_4hk3\"\u003e\u003cdiv class=\"_34js _1kaa _34jv\" id=\"u_fetchstream_23_1c\"\u003e\u003cdiv class=\"_34jx _2cpc _34ju\"\u003e\u003cdiv class=\"_34k0\"\u003e\u003ci class=\"_34k2\"\u003e\u003c/i\u003e\u003c/div\u003e\u003cdiv class=\"_34k3\"\u003ePaid for by Warren for Pr...\u003c/div\u003e\u003ca class=\"_34k6\" href=\"#\" id=\"u_fetchstream_23_1d\"\u003e\u003c/a\u003e\u003c/div\u003e\u003cdiv class=\"_34jw\"\u003e\u003c/div\u003e\u003c/div\u003e\u003cdiv class=\"_1dp8\"\u003e\u003c/div\u003e\u003c/div\u003e\u003c/div\u003e\u003c/div\u003e\u003c/div\u003e\u003c/div\u003e\u003cdiv\u003e\u003c/div\u003e\u003c/div\u003e\u003c/div\u003e\u003cdiv\u003e\u003c/div\u003e\u003c/div\u003e", 85 | "political":1, 86 | "not_political":0, 87 | "title":"Elizabeth Warren", 88 | "message":"\u003cp\u003eLet's get organized! Team Warren will convene a volunteer training in Charlotte on Tuesday, October 1. \u003c/p\u003e\u003cp\u003e Join us to learn more about how to spread Elizabeth’s vision for big, structural change.\u003c/p\u003e", 89 | "thumbnail":"https://fbpac-ads-public.s3.amazonaws.com/v/t1.0-1/p32x32/62053379_10156558715523687_1252765707393826816_n.jpg", 90 | "created_at":"2019-09-24T15:17:02.877Z", 91 | "updated_at":"2019-09-24T17:45:55.601Z", 92 | "lang":"en-US", 93 | "images":[ 94 | "https://fbpac-ads-public.s3.amazonaws.com/v/t45.1600-4/c0.21.1140.598a/p1140x598/68220371_23843666791720770_6870780649381298176_n.png" 95 | ], 96 | "impressions":1, 97 | "political_probability":0.998508825583722, 98 | "targeting":null, 99 | "suppressed":false, 100 | "targets":[ 101 | 102 | ], 103 | "advertiser":"Elizabeth Warren", 104 | "entities":[ 105 | 106 | ], 107 | "page":"https://www.facebook.com/ElizabethWarren/", 108 | "lower_page":"https://www.facebook.com/elizabethwarren/", 109 | "targetings":null, 110 | "paid_for_by":"Warren for President", 111 | "targetedness":null, 112 | "listbuilding_fundraising_proba":null, 113 | "writable_ad":{ 114 | "id":2672179, 115 | "partisanship":null, 116 | "purpose":null, 117 | "optimism":null, 118 | "attack":null, 119 | "archive_id":null, 120 | "created_at":"2020-01-24T19:20:16.328Z", 121 | "updated_at":"2020-01-24T19:20:16.328Z", 122 | "text_hash":"34bdaea8efc6ac866e3b4b95664338eb0b121830", 123 | "ad_id":"23843761749580770" 124 | } 125 | }, 126 | { 127 | "id":"23843761749590770", 128 | "html":"\u003cdiv class=\"_5pa- userContentWrapper\"\u003e\u003cdiv class=\"_1dwg _1w_m\"\u003e\u003cdiv\u003e\u003cdiv class=\"n_1et7rg-s22 c_1et7rh2qkn clearfix\"\u003e\u003cdiv class=\"clearfix s_1et7rg-tsr\"\u003e\u003ca class=\"_5pb8 g_1et7rh2qkg _8o _8s lfloat _ohe\" data-hovercard=\"https://www.facebook.com/38471053686\" href=\"https://www.facebook.com/ElizabethWarren/\"\u003e\u003cdiv class=\"_38vo\"\u003e\u003c!-- react-mount-point-unstable --\u003e\u003cdiv\u003e\u003cimg class=\"_s0 _4ooo _5xib _44ma _54ru img\" src=\"https://fbpac-ads-public.s3.amazonaws.com/v/t1.0-1/p32x32/62053379_10156558715523687_1252765707393826816_n.jpg\"\u003e\u003c/div\u003e\u003c/div\u003e\u003c/a\u003e\u003cdiv class=\"clearfix _42ef\"\u003e\u003cdiv class=\"rfloat _ohf\"\u003e\u003c/div\u003e\u003cdiv class=\"t_1et7rg-tsk\"\u003e\u003cdiv\u003e\u003cdiv class=\"_6a _5u5j\"\u003e\u003cdiv class=\"_6a _6b\"\u003e\u003c/div\u003e\u003cdiv class=\"_6a _5u5j _6b\"\u003e\u003ch6 class=\"_7tae _14f3 _14f5 _5pbw _5vra\"\u003e\u003cspan class=\"fwn fcg\"\u003e\u003cspan class=\"fcg\"\u003e\u003cspan class=\"fwb\"\u003e\u003ca class=\"profileLink\" data-hovercard=\"https://www.facebook.com/38471053686\" href=\"https://www.facebook.com/ElizabethWarren/\"\u003eElizabeth Warren\u003c/a\u003e\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003c/h6\u003e\u003cdiv\u003e\u003ca class=\"_5pcq\" href=\"#\" id=\"u_fetchstream_29_2e\"\u003e\u003cspan\u003e\u003cspan class=\"_3nlk\"\u003eSponsored\u003c/span\u003e ⋅ Paid for by \u003cspan class=\"_3nlk\"\u003eWarren for President\u003c/span\u003e\u003c/span\u003e\u003c/a\u003e\u003cspan class=\"_6spk\"\u003e · \u003c/span\u003e\u003ca class=\"uiStreamPrivacy inlineBlock fbStreamPrivacy fbPrivacyAudienceIndicator _5pcq\" href=\"#\"\u003e\u003ci class=\"lock img sp_gNqCxYl3J40 sx_689d9c\"\u003e\u003c/i\u003e\u003c/a\u003e\u003c/div\u003e\u003c/div\u003e\u003c/div\u003e\u003c/div\u003e\u003c/div\u003e\u003c/div\u003e\u003c/div\u003e\u003c/div\u003e\u003cdiv class=\"_5pbx userContent _3576\" id=\"js_cvz\"\u003e\u003cp\u003eLet's get organized! Team Warren will convene a volunteer training in Charlotte on Tuesday, October 1. \u003c/p\u003e\u003cp\u003e Join us to learn more about how to spread Elizabeth’s vision for big, structural change.\u003c/p\u003e\u003c/div\u003e\u003cdiv class=\"_3x-2\"\u003e\u003cdiv\u003e\u003cdiv class=\"mtm\"\u003e\u003cdiv class=\"_1ci8\"\u003e\u003cdiv\u003e\u003cdiv class=\"_6l4 _59ap _64lx _3eqz _1-9r _2rk1 _20pq _3eqw _3n1j\"\u003e\u003cdiv class=\"_5inf\"\u003e\u003cdiv class=\"uiScaledImageContainer _6m5 fbStoryAttachmentImage _5ind\"\u003e\u003cimg class=\"scaledImageFitWidth img\" src=\"https://fbpac-ads-public.s3.amazonaws.com/v/t45.1600-4/c0.21.1140.598a/p1140x598/68220371_23843666791720770_6870780649381298176_n.png\"\u003e\u003c/div\u003e\u003ca class=\"_5ine\" href=\"https://www.facebook.com/events/313321032835343/\"\u003e\u003c/a\u003e\u003c/div\u003e\u003cdiv class=\"_3z2 _3z3 _2v9b\"\u003e\u003cdiv\u003e\u003cdiv class=\"_3ekx _29_4 _fwr\"\u003e\u003cdiv class=\"_44ae _651x\"\u003e\u003cdiv class=\"_6m3 _--6 _4dhn _opx\"\u003e\u003cdiv class=\"_59tj _2iau _oq1\"\u003e\u003cdiv class=\"_6lz _6mb _1t62\"\u003e\u003cdiv\u003eTUE, OCT 1 AT 7 PM\u003c/div\u003e\u003c/div\u003e\u003c/div\u003e\u003cdiv class=\"_3n1k\"\u003e\u003cdiv class=\"_6m6 _2cnj\"\u003e\u003ca href=\"https://www.facebook.com/events/313321032835343/\"\u003eCharlotte Barnstorm Volunteer Training with Team Warren\u003c/a\u003e\u003c/div\u003e\u003cdiv class=\"_6m7 _3bt9 hidden_elem\"\u003e\u003cdiv class=\"fsm fwn fcg\"\u003e\u003cspan\u003eCharlotte, NC\u003c/span\u003e\u003c/div\u003e\u003c/div\u003e\u003c/div\u003e\u003cdiv class=\"_20l4 _svw _5inn\"\u003e310 people interested · 34 people going\u003c/div\u003e\u003c/div\u003e\u003cdiv class=\"_44af _2e6-\"\u003e\u003cdiv class=\"_6a _6b _fw_ _fx0\"\u003e\u003cdiv class=\"_19ab\" id=\"u_fetchstream_29_2h\"\u003e\u003cspan id=\"u_fetchstream_29_2j\"\u003e\u003ca class=\"_42ft _4jy0 fbEventAttachmentCTAButton _522u _4jy3 _517h _51sy\" href=\"#\"\u003e\u003ci class=\"_3-8_ img sp_kJazzH4jvE1 sx_634b40\"\u003e\u003c/i\u003eInterested\u003c/a\u003e\u003c/span\u003e\u003c/div\u003e\u003c/div\u003e\u003c/div\u003e\u003c/div\u003e\u003c/div\u003e\u003c/div\u003e\u003c/div\u003e\u003c/div\u003e\u003c/div\u003e\u003cdiv class=\"_4hk3\"\u003e\u003cdiv class=\"_34js _1kaa _34jv\" id=\"u_fetchstream_29_2k\"\u003e\u003cdiv class=\"_34jx _2cpc _34ju\"\u003e\u003cdiv class=\"_34k0\"\u003e\u003ci class=\"_34k2\"\u003e\u003c/i\u003e\u003c/div\u003e\u003cdiv class=\"_34k3\"\u003ePaid for by Warren for Pr...\u003c/div\u003e\u003ca class=\"_34k6\" href=\"#\" id=\"u_fetchstream_29_2l\"\u003e\u003c/a\u003e\u003c/div\u003e\u003cdiv class=\"_34jw\"\u003e\u003c/div\u003e\u003c/div\u003e\u003cdiv class=\"_1dp8\"\u003e\u003c/div\u003e\u003c/div\u003e\u003c/div\u003e\u003c/div\u003e\u003c/div\u003e\u003c/div\u003e\u003cdiv\u003e\u003c/div\u003e\u003c/div\u003e\u003c/div\u003e\u003cdiv\u003e\u003c/div\u003e\u003c/div\u003e", 129 | "political":3, 130 | "not_political":0, 131 | "title":"Elizabeth Warren", 132 | "message":"\u003cp\u003eLet's get organized! Team Warren will convene a volunteer training in Charlotte on Tuesday, October 1. \u003c/p\u003e\u003cp\u003e Join us to learn more about how to spread Elizabeth’s vision for big, structural change.\u003c/p\u003e", 133 | "thumbnail":"https://fbpac-ads-public.s3.amazonaws.com/v/t1.0-1/p32x32/62053379_10156558715523687_1252765707393826816_n.jpg", 134 | "created_at":"2019-09-27T00:07:27.565Z", 135 | "updated_at":"2019-09-27T01:30:31.743Z", 136 | "lang":"en-US", 137 | "images":[ 138 | "https://fbpac-ads-public.s3.amazonaws.com/v/t45.1600-4/c0.21.1140.598a/p1140x598/68220371_23843666791720770_6870780649381298176_n.png" 139 | ], 140 | "impressions":1, 141 | "political_probability":0.998567219990821, 142 | "targeting":null, 143 | "suppressed":false, 144 | "targets":[ 145 | 146 | ], 147 | "advertiser":"Elizabeth Warren", 148 | "entities":[ 149 | 150 | ], 151 | "page":"https://www.facebook.com/ElizabethWarren/", 152 | "lower_page":"https://www.facebook.com/elizabethwarren/", 153 | "targetings":null, 154 | "paid_for_by":"Warren for President", 155 | "targetedness":null, 156 | "listbuilding_fundraising_proba":null, 157 | "writable_ad":{ 158 | "id":2672180, 159 | "partisanship":null, 160 | "purpose":null, 161 | "optimism":null, 162 | "attack":null, 163 | "archive_id":null, 164 | "created_at":"2020-01-24T19:20:16.336Z", 165 | "updated_at":"2020-01-24T19:20:16.336Z", 166 | "text_hash":"34bdaea8efc6ac866e3b4b95664338eb0b121830", 167 | "ad_id":"23843761749590770" 168 | } 169 | } 170 | ] 171 | } --------------------------------------------------------------------------------