├── .gitignore
├── .vscode
└── settings.json
├── LICENSE
├── README.md
├── build.js
├── docs
├── .nojekyll
├── README.md
├── _sidebar.md
├── add-scraper.md
├── codebase.md
├── css
│ └── custom.css
├── devenv.md
├── examples.md
├── examples
│ ├── _images
│ │ ├── airbnb
│ │ │ ├── annotations.png
│ │ │ ├── ranked_by_price.png
│ │ │ ├── ranked_by_rating.png
│ │ │ ├── wildcard_closed.png
│ │ │ └── wildcard_open.png
│ │ ├── amazon
│ │ │ ├── ascended_sort.png
│ │ │ ├── cheap_item.png
│ │ │ ├── condition_sort_vid.gif
│ │ │ ├── condition_sort_vid.mp4
│ │ │ ├── descended_sort.png
│ │ │ ├── price_sort_vid.gif
│ │ │ ├── price_sort_vid.mp4
│ │ │ ├── select_cell_highlighted.png
│ │ │ ├── wildcard_closed.png
│ │ │ └── wildcard_open.png
│ │ ├── blogger
│ │ │ ├── edited_html.png
│ │ │ ├── edited_text.png
│ │ │ ├── wildcard_closed.png
│ │ │ └── wildcard_open.png
│ │ ├── hackernews
│ │ │ ├── annotation_vid.gif
│ │ │ ├── annotation_vid.mp4
│ │ │ ├── annotations.png
│ │ │ ├── ranked_by_comments.png
│ │ │ ├── ranked_by_points.png
│ │ │ ├── wildcard_closed.png
│ │ │ └── wildcard_open.png
│ │ ├── weather
│ │ │ ├── warmest.png
│ │ │ ├── wildcard_closed.png
│ │ │ └── wildcard_open.png
│ │ └── youtube
│ │ │ ├── ranked_by_watch_time.png
│ │ │ └── wildcard_open.png
│ ├── airbnb.md
│ ├── amazon.md
│ ├── blogger.md
│ ├── hackernews.md
│ ├── template.md
│ ├── ubereats.md
│ ├── weather.md
│ └── youtube.md
├── index.html
└── quickstart.md
├── manifest.json
├── options.html
├── options.js
├── package-lock.json
├── package.json
├── readme-resources
└── architecture-v02.png
├── resources
└── adapter-configs
│ ├── eecscoursecatalog.json
│ ├── github.json
│ ├── hackernews.json
│ ├── mitcoursecatalog.json
│ └── youtube.json
├── src
├── core
│ ├── actions.ts
│ ├── debug.tsx
│ ├── getFinalTable.ts
│ ├── reducer.ts
│ └── types.ts
├── end_user_scraper
│ ├── adapterHelpers.ts
│ ├── constants.ts
│ ├── domHelpers.ts
│ ├── eventListeners.ts
│ ├── generalizer.ts
│ ├── index.ts
│ ├── state.ts
│ ├── tutorial.ts
│ └── utils.ts
├── formula.ts
├── localStorageAdapter.ts
├── marketplace.js
├── site_adapters
│ ├── airbnb.ts
│ ├── amazon.ts
│ ├── blogger.ts
│ ├── domScrapingBase.ts
│ ├── expedia.ts
│ ├── flux.ts
│ ├── github.ts
│ ├── hackerNews.ts
│ ├── harvardbookwarehouse.ts
│ ├── index.ts
│ ├── instacart.ts
│ ├── ubereats.ts
│ ├── weatherchannel.ts
│ └── youtube.ts
├── tableAdapterMiddleware.ts
├── ui
│ ├── AutosuggestInput.tsx
│ ├── WcPanel.tsx
│ ├── cell_editors
│ │ ├── formulaEditor.js
│ │ ├── fullCalendarEditor.css
│ │ ├── fullCalendarEditor.js
│ │ ├── richTextEditor.css
│ │ └── richTextEditor.js
│ └── overrides.css
├── utils.ts
├── wildcard-ajax.js
├── wildcard-background.ts
└── wildcard.tsx
├── tsconfig.json
├── vendor
├── ace
│ ├── ace.js
│ ├── mode-typescript.js
│ └── theme-monokai.js
├── bootstrap
│ ├── css
│ │ └── bootstrap.min.css
│ └── js
│ │ └── bootstrap.min.js
└── jquery.js
└── yarn.lock
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules/*
2 | **/.DS_Store
3 | *.sublime-project
4 | *.sublime-workspace
5 | .idea/
6 | dist/*
7 | .vscode/*
8 | yarn-error.log
9 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 |
3 | }
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2025 Geoffrey Litt
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Wildcard
2 |
3 | Wildcard is a platform that empowers anyone to build browser extensions and modify websites to meet their own specific needs.
4 |
5 | Wildcard shows a simplified view of the data in a web page as a familiar table view. People can directly manipulate the table to sort/filter content, add annotations, and even use spreadsheet-style formulas to pull in data from other websites.
6 |
7 | * [Documentation](https://geoffreylitt.github.io/wildcard)
8 | * [Project homepage](https://www.geoffreylitt.com/wildcard/)
9 | * [Research paper](https://www.geoffreylitt.com/wildcard/salon2020/) presented at the Convivial Computing Salon 2020
10 |
11 | ## Install Wildcard
12 |
13 | Be aware: Wildcard is still pre-release software.
14 |
15 | **The current master branch is stable, but missing a few important features
16 | that have yet to be ported over from previous versions of the codebase: mainly filtering the table and formulas.**
17 |
18 | To get an email when a full featured version is ready, [sign up for the email newsletter](https://tinyletter.com/wildcard-extension).
19 |
20 | If you want to install Wildcard today, there are two options:
21 |
22 | To use Wildcard with an existing library of supported websites, follow the [quick start instructions](https://geoffreylitt.github.io/wildcard/#/quickstart).
23 |
24 | ## Contribute
25 |
26 | Follow the [dev env install instructions](https://geoffreylitt.github.io/wildcard/#/devenv).
27 |
28 | To run the build watcher: `yarn run dev`
29 |
30 | To run the extension in a new browser and auto-reload it when you update the code: `yarn run chrome` or `yarn run firefox`
--------------------------------------------------------------------------------
/build.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 | //
3 | // This example builds a module in both debug and release mode.
4 | // See estrella.d.ts for documentation of available options.
5 | // You can also pass any options for esbuild (as defined in esbuild/lib/main.d.ts).
6 | //
7 | const { build, cliopts } = require("estrella")
8 | const webExt = require("web-ext")
9 |
10 | build({
11 | entry: "src/wildcard.tsx",
12 | outfile: "dist/wildcard.js",
13 | bundle: true,
14 | sourcemap: true,
15 | define: {
16 | "process.env.NODE_ENV": '"production"'
17 | },
18 | minify: false
19 | })
20 |
21 | build({
22 | entry: "src/wildcard-background.ts",
23 | outfile: "dist/wildcard-background.js",
24 | bundle: true,
25 | sourcemap: true,
26 | define: {
27 | "process.env.NODE_ENV": '"production"'
28 | },
29 | })
30 |
31 |
32 | build({
33 | entry: "src/marketplace.js",
34 | outfile: "dist/marketplace.js",
35 | bundle: true,
36 | sourcemap: true,
37 | define: {
38 | "process.env.NODE_ENV": '"production"'
39 | },
40 | })
--------------------------------------------------------------------------------
/docs/.nojekyll:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/geoffreylitt/wildcard/dd81d9b4a34729887e86b2e5de0c3e8ecdc94b03/docs/.nojekyll
--------------------------------------------------------------------------------
/docs/README.md:
--------------------------------------------------------------------------------
1 | # Wildcard
2 |
3 | Wildcard is a platform that empowers anyone to build browser extensions and modify websites to meet their own specific needs.
4 |
5 | Wildcard shows a simplified view of the data in a web page as a familiar table view. People can directly manipulate the table to sort/filter content, add annotations, and even use spreadsheet-style formulas to pull in data from other websites.
6 |
7 | For more details, see the [paper](https://www.geoffreylitt.com/wildcard/salon2020/) presented at the Convivial Computing Salon 2020.
8 |
9 | ## Get started
10 |
11 | * Read the [quick start](quickstart.md) for installation instructions
12 | * View the [example gallery](examples.md) to see some things you can do with Wildcard
13 |
14 | ## Customize
15 |
16 | If you know how to program in Javascript, you can write little bits of scraping code to make Wildcard work with any website. See [the instructions](add-scraper.md) for more details.
17 |
18 | ## Stay up to date
19 |
20 | [Sign up for the email newsletter](https://forms.gle/mpn1Hn8Ln7dmPo6T8) to get occasional updates about the project.
21 |
--------------------------------------------------------------------------------
/docs/_sidebar.md:
--------------------------------------------------------------------------------
1 |
2 | - [Home](README.md)
3 | - Getting started
4 |
5 | - [Quick start](quickstart.md)
6 | - [Example gallery](examples.md)
7 | - [Amazon](examples/amazon.md)
8 | - [AirBnB](examples/airbnb.md)
9 | - [Blogger](examples/blogger.md)
10 | - [Hackernews](examples/hackernews.md)
11 | - [Weather](examples/weather.md)
12 | - [Youtube](examples/youtube.md)
13 |
14 |
15 | - Customize
16 | - [Set up dev environment](devenv.md)
17 | - [Adding a site adapter](add-scraper.md)
18 | - [Understanding the codebase](codebase.md)
19 |
--------------------------------------------------------------------------------
/docs/add-scraper.md:
--------------------------------------------------------------------------------
1 | # Adding a site adapter
2 |
3 | !> These are very preliminary docs. If you get stuck or run into questions/issues, file a Github issue on this repo or [reach out over email](mailto:glitt@mit.edu).
4 |
5 | ## Steps
6 |
7 | To use a new website in Wildcard you need to write a "site adapter",
8 | which is a bit of Javascript that scrapes data from a web page.
9 |
10 | First, install a [development environment](https://geoffreylitt.github.io/wildcard/#/devenv)
11 | and run `yarn run dev` to get changes automatically compiling.
12 |
13 | 1. Copy one of the existing site adapters in `src/site_adapters` as an example template.
14 | 2. Change the name of the adapter to your new adapter name.
15 | 3. Register the new copied adapter in `src/site_adapters/index.ts`.
16 | 4. Make changes to the copied adapter to make it work for your site. See if you can get a table of data to show up. Using the dev tools DOM inspector and adding `console.log` statements to your adapter might help you get through the scraping logic.
17 |
18 | Here's [an example commit](https://github.com/geoffreylitt/wildcard/commit/42fbb748a809aa84b7f6927a9aac02376f5bb926) of adding a site adapter. Your commit should look something like this one.
19 |
20 | ## Video tutorial
21 |
22 | Here's a 30 minute video tutorial of how to make a site adapter, in detail:
23 |
24 |
25 |
--------------------------------------------------------------------------------
/docs/codebase.md:
--------------------------------------------------------------------------------
1 | # Understanding the codebase
2 |
3 | !> This doc is only intended for people who want to hack on
4 | the internals of Wildcard. If you just want to add a site adapter
5 | to scrape a specific site, see [Adding a scraper](add-scraper.md)
6 |
7 | ## Background
8 |
9 | It helps to understand the basic ideas of React + Redux to understand this codebase. Some good introductory reads:
10 |
11 | * [Redux core concepts](https://redux.js.org/introduction/core-concepts)
12 | * [Redux data flow](https://redux.js.org/basics/data-flow)
13 | * [Thinking in React](https://reactjs.org/docs/thinking-in-react.html)
14 |
15 | The code is split into three main modules, each with their own directory inside `src`. Here's a quick overview of the contents of each module.
16 |
17 | 
18 |
19 | ## Core
20 |
21 | Maintains system state. Defines Redux actions and reducers.
22 |
23 | The root state object looks something like this.
24 |
25 | ```ts
26 | {
27 | // Latest snapshot of data extracted from the original site.
28 | appTable: {
29 | attributes: [...], // columns of the table
30 | records: [...], // rows of the table
31 | },
32 | // All the user's annotations and formulas associated with this site
33 | userTable: {
34 | attributes: [...],
35 | records: [...],
36 | },
37 | // Configuration for the current "query" being shown:
38 | // * sorting metadata
39 | // * filtering metadata (not added yet)
40 | query: {
41 | sortConfig: {
42 | attribute: "name",
43 | direction: "asc"
44 | }
45 | }
46 | }
47 | ```
48 |
49 | ### Assembling query views
50 |
51 | Note how the app's data and user's data are stored independently.
52 | Before we can display it in the table UI, we need to combine it together.
53 |
54 | The core contains logic for executing "queries" that
55 | combine data extracted from the original site with
56 | data maintained by the user, and sort/filter the result.
57 | The query results aren't stored anywhere; they get recomputed
58 | every time the underlying state changes in any way. This makes it trivial
59 | to show correct data in the table UI whenever new data comes in,
60 | either from the site adapter or the user.
61 |
62 | ### Actions
63 |
64 | Per the Redux pattern, system state is only modified through specified actions,
65 | which come from the site adapter or the UI.
66 |
67 | For example, the site adapter can dispatch a "load records" action when new
68 | data is available, or the UI can dispatch a "sort table" action.
69 |
70 | See `src/core/actions.ts` for a full list of actions.
71 |
72 | ## Site adapters
73 |
74 | A Wildcard site adapter connects a specific site/application to Wildcard.
75 | All site adapters must fulfill this abstract interface, which boils down to:
76 |
77 | * Read data from the site, updating when necessary
78 | * When data updates (eg user sorts or adds annotations), reify changes in the site
79 |
80 | ```ts
81 | export interface SiteAdapter {
82 | // =====================
83 | // Reading data from the site
84 | // =====================
85 |
86 | /** Return latest data from the site */
87 | loadRecords():Array;
88 |
89 | /** Register a callback function which will be called with a new table
90 | * of data anytime the data changes. */
91 | subscribe (callback:(table:Table) => void):void;
92 |
93 | // =====================
94 | // Modifying the site UI
95 | // =====================
96 |
97 | /** Apply a new sort order to the UI */
98 | applySort(finalRecords:Array, sortConfig:SortConfig):void;
99 |
100 | /** Apply a new annotation to the UI */
101 | annotateRecordInSite(id:id, newValues:any, userAttributes:Array):void;
102 |
103 | // I'm considering replacing the two functions above with a generalized
104 | // version that can apply arbitrary table state to the UI:
105 |
106 | /** Update the UI to match arbitrary table state
107 | * (To implement performantly, probably do a diff inside the adapter
108 | * and only update the UI where necessary) */
109 | update?(table:Table):void;
110 | }
111 | ```
112 |
113 | Currently there's one specific type of site adapter used in the system:
114 | DOM Scraping adapters, which scrape the website DOM to fetch data,
115 | and manipulate the DOM to update the page.
116 |
117 | `DomScrapingBaseAdapter` is an abstract base class that new adapters
118 | can inherit from to implement DOM scraping. A concrete DOM scraping adapter
119 | only needs to implement one main function that scrapes data,
120 | and the base class takes care of the rest—
121 | see `src/site_adapters/newHN.ts` for one example of a concrete scraping adapter.
122 |
123 | In the future we anticipate having other categories of site adapters,
124 | which will fulfill the same abstract interface with different techniques:
125 |
126 | * AJAX Scraping: scrape data out of AJAX JSON requests
127 | * Redux adapter: extract data directly out of the application's internal Redux store
128 |
129 | ## UI
130 |
131 | A Wildcard UI has these responsibilities:
132 |
133 | * Display the table resulting from a query
134 | * Dispatch semantic events based on user interactions
135 |
136 | Currently there's just one UI, the `WcPanel` React Component that is a stateless table editor
137 | component built on Handsontable. (`src/ui/WcPanel.tsx`)
138 |
139 | The most important thing to realize is that the UI is **stateless**.
140 | It is a pure function of the Redux state, and displays the "query view"
141 | computed by the Core. All mutation happens by triggering Redux events.
142 | This makes the UI have minimal responsibility, and allows for future
143 | other UIs that view the tabular data in different ways.
144 |
145 | `src/wildcard.ts` is the final file that pulls everything together and
146 | injects Wildcard into the page.
147 |
148 |
149 |
--------------------------------------------------------------------------------
/docs/css/custom.css:
--------------------------------------------------------------------------------
1 | .gallery-wrapper{
2 | display: grid;
3 | grid-template-columns: repeat(2, 1fr);
4 | grid-template-rows: repeat(3, 250px);
5 | grid-gap: .8rem;
6 | }
7 |
8 | .imageTarget{
9 | width: 350px;
10 | height: 200px;
11 | border: 2px solid #42B983;
12 | }
13 |
14 |
15 | .w3-display-container{
16 | max-width:800px;
17 | }
18 |
19 | .galleryImages{
20 | display: none;
21 | width:100%;
22 | }
23 |
24 | figcaption{
25 | display: none;
26 | text-align: center;
27 | padding: 0.5em;
28 | font-weight: bold;
29 | }
30 |
31 | .w3-left, .w3-right, .w3-badge {
32 | cursor:pointer;
33 | color: #42B983;
34 | }
35 |
36 | .w3-left, .w3-right{
37 | font-size: 60px;
38 | opacity: .8;
39 | }
40 |
41 | .w3-left:hover, .w3-right:hover{
42 | color: teal;
43 | }
44 |
45 | .w3-badge {
46 | height:13px;
47 | width:13px;
48 | padding:0;
49 | }
--------------------------------------------------------------------------------
/docs/devenv.md:
--------------------------------------------------------------------------------
1 | # Development environment setup
2 |
3 | ## Initial setup
4 |
5 | To install Wildcard in a way that supports adding new site adapters,
6 | or making other changes to the framework,
7 | you'll need to set up the local development environment. Wildcard is built in Typescript and uses yarn and rollup for packages and bundling.
8 |
9 | !> If you don't want to add new scrapers to Wildcard, you can follow the simpler [quick start](quickstart.md) instructions instead.
10 |
11 | Clone the Github repo: `git clone git@github.com:geoffreylitt/wildcard.git`
12 |
13 | Install dependencies :
14 |
15 | * `yarn install` (or `npm install`)
16 | * `yarn global add docsify`
17 |
18 | Build the project: `yarn run dev`. This will compile the Typescript project and create files in the `./dist` folder.
19 |
20 | There are two ways you can install the extension.
21 |
22 | ### Install in your main browser
23 |
24 | Follow [the quick start instructions](quickstart.md) to install the built project directory in your browser.
25 |
26 | This is nice if you want to use Wildcard while you actually browse the web.
27 |
28 | But, it's not great for development, because it's hard to reload the extension automatically when you make changes. For dev, there's a better way:
29 |
30 | ### Run through web-ext in development
31 |
32 | You can run a special browser which will automatically reload the extension when you make changes. Run `yarn run chrome` to start this up.
33 |
34 | To test if you're able to make changes, try adding a `console.log` statement to `src/wildcard.tsx` and see if it shows up in your browser.
35 |
36 | In general while developing you want to run `yarn run dev` and `yarn run chrome` in separate terminal tabs.
37 |
38 | ## Documentation
39 |
40 | This documentation page is built with [Docsify](https://docsify.js.org/).
41 |
42 | To edit docs, edit markdown files in `./docs`.
43 |
44 | To view docs locally, run `yarn run docs`.
45 |
46 | ## To cut a release
47 |
48 | * create a new folder named `wildcard-`
49 | * copy the current dist folder and manifest.json to the new folder
50 | * zip the folder. Create a new release in the Github UI and upload the zip file.
51 |
52 | (In the future we should automate this)
53 |
--------------------------------------------------------------------------------
/docs/examples.md:
--------------------------------------------------------------------------------
1 | # Example gallery
2 |
3 | Wildcard provides a wide variety of flexibility and personalization for users. With Wildcard, you can sort elements on a page based on a metric such as price or rating, import your favorite rich text editor, or make annotations on items when shopping.
4 |
5 | Here are some examples of how Wildcard in use on some websites:
6 |
7 |
8 |
9 |
10 |
11 | #### [1. Amazon: Tally and sort prices](examples/amazon.md)
12 |
17 |
18 |
19 |
20 |
21 |
22 | #### [2. AirBnB: Annotate listings](examples/airbnb.md)
23 |
28 |
29 |
30 |
31 |
32 |
33 | #### [3. Blogger: Import rich text editors](examples/blogger.md)
34 |
39 |
40 |
41 |
42 |
43 |
44 | #### [4. Hackernews: Find popular news](examples/hackernews.md)
45 |
50 |
51 |
52 |
53 |
54 |
55 | #### [5. Weather: Find the best weather](examples/weather.md)
56 |
61 |
62 |
63 |
64 |
65 |
66 | #### [6. Youtube: Sort videos by watch time](examples/youtube.md)
67 |
72 |
73 |
74 |
--------------------------------------------------------------------------------
/docs/examples/_images/airbnb/annotations.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/geoffreylitt/wildcard/dd81d9b4a34729887e86b2e5de0c3e8ecdc94b03/docs/examples/_images/airbnb/annotations.png
--------------------------------------------------------------------------------
/docs/examples/_images/airbnb/ranked_by_price.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/geoffreylitt/wildcard/dd81d9b4a34729887e86b2e5de0c3e8ecdc94b03/docs/examples/_images/airbnb/ranked_by_price.png
--------------------------------------------------------------------------------
/docs/examples/_images/airbnb/ranked_by_rating.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/geoffreylitt/wildcard/dd81d9b4a34729887e86b2e5de0c3e8ecdc94b03/docs/examples/_images/airbnb/ranked_by_rating.png
--------------------------------------------------------------------------------
/docs/examples/_images/airbnb/wildcard_closed.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/geoffreylitt/wildcard/dd81d9b4a34729887e86b2e5de0c3e8ecdc94b03/docs/examples/_images/airbnb/wildcard_closed.png
--------------------------------------------------------------------------------
/docs/examples/_images/airbnb/wildcard_open.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/geoffreylitt/wildcard/dd81d9b4a34729887e86b2e5de0c3e8ecdc94b03/docs/examples/_images/airbnb/wildcard_open.png
--------------------------------------------------------------------------------
/docs/examples/_images/amazon/ascended_sort.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/geoffreylitt/wildcard/dd81d9b4a34729887e86b2e5de0c3e8ecdc94b03/docs/examples/_images/amazon/ascended_sort.png
--------------------------------------------------------------------------------
/docs/examples/_images/amazon/cheap_item.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/geoffreylitt/wildcard/dd81d9b4a34729887e86b2e5de0c3e8ecdc94b03/docs/examples/_images/amazon/cheap_item.png
--------------------------------------------------------------------------------
/docs/examples/_images/amazon/condition_sort_vid.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/geoffreylitt/wildcard/dd81d9b4a34729887e86b2e5de0c3e8ecdc94b03/docs/examples/_images/amazon/condition_sort_vid.gif
--------------------------------------------------------------------------------
/docs/examples/_images/amazon/condition_sort_vid.mp4:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/geoffreylitt/wildcard/dd81d9b4a34729887e86b2e5de0c3e8ecdc94b03/docs/examples/_images/amazon/condition_sort_vid.mp4
--------------------------------------------------------------------------------
/docs/examples/_images/amazon/descended_sort.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/geoffreylitt/wildcard/dd81d9b4a34729887e86b2e5de0c3e8ecdc94b03/docs/examples/_images/amazon/descended_sort.png
--------------------------------------------------------------------------------
/docs/examples/_images/amazon/price_sort_vid.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/geoffreylitt/wildcard/dd81d9b4a34729887e86b2e5de0c3e8ecdc94b03/docs/examples/_images/amazon/price_sort_vid.gif
--------------------------------------------------------------------------------
/docs/examples/_images/amazon/price_sort_vid.mp4:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/geoffreylitt/wildcard/dd81d9b4a34729887e86b2e5de0c3e8ecdc94b03/docs/examples/_images/amazon/price_sort_vid.mp4
--------------------------------------------------------------------------------
/docs/examples/_images/amazon/select_cell_highlighted.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/geoffreylitt/wildcard/dd81d9b4a34729887e86b2e5de0c3e8ecdc94b03/docs/examples/_images/amazon/select_cell_highlighted.png
--------------------------------------------------------------------------------
/docs/examples/_images/amazon/wildcard_closed.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/geoffreylitt/wildcard/dd81d9b4a34729887e86b2e5de0c3e8ecdc94b03/docs/examples/_images/amazon/wildcard_closed.png
--------------------------------------------------------------------------------
/docs/examples/_images/amazon/wildcard_open.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/geoffreylitt/wildcard/dd81d9b4a34729887e86b2e5de0c3e8ecdc94b03/docs/examples/_images/amazon/wildcard_open.png
--------------------------------------------------------------------------------
/docs/examples/_images/blogger/edited_html.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/geoffreylitt/wildcard/dd81d9b4a34729887e86b2e5de0c3e8ecdc94b03/docs/examples/_images/blogger/edited_html.png
--------------------------------------------------------------------------------
/docs/examples/_images/blogger/edited_text.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/geoffreylitt/wildcard/dd81d9b4a34729887e86b2e5de0c3e8ecdc94b03/docs/examples/_images/blogger/edited_text.png
--------------------------------------------------------------------------------
/docs/examples/_images/blogger/wildcard_closed.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/geoffreylitt/wildcard/dd81d9b4a34729887e86b2e5de0c3e8ecdc94b03/docs/examples/_images/blogger/wildcard_closed.png
--------------------------------------------------------------------------------
/docs/examples/_images/blogger/wildcard_open.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/geoffreylitt/wildcard/dd81d9b4a34729887e86b2e5de0c3e8ecdc94b03/docs/examples/_images/blogger/wildcard_open.png
--------------------------------------------------------------------------------
/docs/examples/_images/hackernews/annotation_vid.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/geoffreylitt/wildcard/dd81d9b4a34729887e86b2e5de0c3e8ecdc94b03/docs/examples/_images/hackernews/annotation_vid.gif
--------------------------------------------------------------------------------
/docs/examples/_images/hackernews/annotation_vid.mp4:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/geoffreylitt/wildcard/dd81d9b4a34729887e86b2e5de0c3e8ecdc94b03/docs/examples/_images/hackernews/annotation_vid.mp4
--------------------------------------------------------------------------------
/docs/examples/_images/hackernews/annotations.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/geoffreylitt/wildcard/dd81d9b4a34729887e86b2e5de0c3e8ecdc94b03/docs/examples/_images/hackernews/annotations.png
--------------------------------------------------------------------------------
/docs/examples/_images/hackernews/ranked_by_comments.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/geoffreylitt/wildcard/dd81d9b4a34729887e86b2e5de0c3e8ecdc94b03/docs/examples/_images/hackernews/ranked_by_comments.png
--------------------------------------------------------------------------------
/docs/examples/_images/hackernews/ranked_by_points.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/geoffreylitt/wildcard/dd81d9b4a34729887e86b2e5de0c3e8ecdc94b03/docs/examples/_images/hackernews/ranked_by_points.png
--------------------------------------------------------------------------------
/docs/examples/_images/hackernews/wildcard_closed.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/geoffreylitt/wildcard/dd81d9b4a34729887e86b2e5de0c3e8ecdc94b03/docs/examples/_images/hackernews/wildcard_closed.png
--------------------------------------------------------------------------------
/docs/examples/_images/hackernews/wildcard_open.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/geoffreylitt/wildcard/dd81d9b4a34729887e86b2e5de0c3e8ecdc94b03/docs/examples/_images/hackernews/wildcard_open.png
--------------------------------------------------------------------------------
/docs/examples/_images/weather/warmest.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/geoffreylitt/wildcard/dd81d9b4a34729887e86b2e5de0c3e8ecdc94b03/docs/examples/_images/weather/warmest.png
--------------------------------------------------------------------------------
/docs/examples/_images/weather/wildcard_closed.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/geoffreylitt/wildcard/dd81d9b4a34729887e86b2e5de0c3e8ecdc94b03/docs/examples/_images/weather/wildcard_closed.png
--------------------------------------------------------------------------------
/docs/examples/_images/weather/wildcard_open.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/geoffreylitt/wildcard/dd81d9b4a34729887e86b2e5de0c3e8ecdc94b03/docs/examples/_images/weather/wildcard_open.png
--------------------------------------------------------------------------------
/docs/examples/_images/youtube/ranked_by_watch_time.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/geoffreylitt/wildcard/dd81d9b4a34729887e86b2e5de0c3e8ecdc94b03/docs/examples/_images/youtube/ranked_by_watch_time.png
--------------------------------------------------------------------------------
/docs/examples/_images/youtube/wildcard_open.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/geoffreylitt/wildcard/dd81d9b4a34729887e86b2e5de0c3e8ecdc94b03/docs/examples/_images/youtube/wildcard_open.png
--------------------------------------------------------------------------------
/docs/examples/airbnb.md:
--------------------------------------------------------------------------------
1 | # AirBnB Gallery
2 |
3 | When people plan travel, they most likely wanted to find the most budget-friendly option for their needs. Sometimes, they prefer to find the most walkable location. The bottom line is that users have different needs when considering accommodation and Wildcard empowers them to do so.
4 |
5 |
6 |
7 |
8 |
9 |
AirBnB Default View.
10 |
11 |
12 |
AirBnB Default View with Wildcard Open.
13 |
14 |
15 |
AirBnB with listings sorted by price.
16 |
17 |
18 |
Users can also sort listings by rating.
19 |
20 |
21 |
One really fascinating thing that you can do with Wildcard is to add annotations/comments to different listings. These annotations persist across page refreshes because they are stored in your browser's local storage.
22 |
23 |
24 |
25 |
❮
26 |
❯
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
--------------------------------------------------------------------------------
/docs/examples/amazon.md:
--------------------------------------------------------------------------------
1 | # Amazon Gallery
2 |
3 | When browsing through used products on Amazon, users are not shown the total cost of these product. With Wildcard, they can not just see the total cost but also sort the items in the page by price, rating, condition, delivery date, etc.
4 |
5 |
6 |
7 |
8 |
Amazon listings before Wildcard is opened
9 |
10 |
11 |
Wildcard opened on the page. Notice how relevant information has been extracted into the spreadsheet.
12 |
13 |
14 |
When the user selects a row, the corresponding element is highlighted in Wildcard.
15 |
16 |
17 |
Wildcard computes the total price from the three listed per item, and can be used to sort the total price in ascending order.
18 |
19 |
20 |
Wildcard used to sort the total price in ascending and descending order.
21 |
22 |
23 |
Additionally, users can perform nested sorts within the table; after the prices are sorted in ascending order, users can run another sort on the conditions. This will order the offer listings based on both price and condition as shown above.
24 |
25 |
26 |
Notice that by sorting the condition of the books, the user will find that the New copy costs only $1.06 more than the used copy.
27 |
28 |
29 |
❮
30 |
❯
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
--------------------------------------------------------------------------------
/docs/examples/blogger.md:
--------------------------------------------------------------------------------
1 | # Blogger Gallery
2 |
3 | Most websites with text areas do not provide rich text editing functionality. Some applications may have text editors but those lack specific functionality that users are looking for. With Wildcard, users can import their favorite text editors into any website. Below, a rich text editor is imported into the Blogger app.
4 |
5 |
6 |
7 |
8 |
Blogger Default View with Wildcard closed.
9 |
10 |
11 |
Blogger Default View with Wildcard open.
12 |
13 |
14 |
A rich text editor chosen by the user is imported through a Wildcard cell editor and can be used to format text.
15 |
16 |
17 |
The rich text editor can also convert formatting into HTML.
18 |
19 |
20 |
21 |
❮
22 |
❯
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
--------------------------------------------------------------------------------
/docs/examples/hackernews.md:
--------------------------------------------------------------------------------
1 | # Hackernews Gallery
2 |
3 | Hackernews is a website that is popular among developers and entrepreneurs. Anybody can create an account and add content which is presented in a timeline format (most recent first).
4 |
5 | But sometimes, users want to see content in a different format.
6 |
7 |
8 |
9 |
10 |
Default View in Timeline Format.
11 |
12 |
13 |
Default View with Wildcard opened.
14 |
15 |
16 |
Content presented based on descending order of points using Wildcard
17 |
18 |
19 |
Content presented based on highest level of engagement.
20 |
21 |
22 |
Users can also add their own annotations to the articles. These annotations persist across page refreshes.
23 |
24 |
25 |
❮
26 |
❯
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
--------------------------------------------------------------------------------
/docs/examples/template.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
FIRST CAPTION
5 |
6 |
7 |
SECOND + OTHER CAPTIONS
8 |
9 |
10 |
11 |
❮
12 |
❯
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
--------------------------------------------------------------------------------
/docs/examples/ubereats.md:
--------------------------------------------------------------------------------
1 | # UberEats Gallery
2 |
3 | When most people go on ubereats, they are looking for a wide variety of things - food to be delivered quickly, restaurants with the lowest delivery fees, among many others. Unfortunately, users can neither filter by price nor by estimated delivery time. With Wildcard, users can do both and find restaurants that suit their needs:
4 |
5 |
--------------------------------------------------------------------------------
/docs/examples/weather.md:
--------------------------------------------------------------------------------
1 | # Weather Gallery
2 |
3 | Most of us have a weather app that we check daily. It usually has a chart that gives an hourly breakdown of the weather. But what if we wanted **more specific** information, eg. during what hour will it be warmest today? Let's see how Wildcard helps us to accomplish this.
4 |
5 |
6 |
7 |
8 |
Weather Default View.
9 |
10 |
11 |
Default View with Wildcard Open.
12 |
13 |
14 |
Wildcard showing the warmest hours of the day.
15 |
16 |
17 |
❮
18 |
❯
19 |
20 |
21 |
22 |
23 |
24 |
--------------------------------------------------------------------------------
/docs/examples/youtube.md:
--------------------------------------------------------------------------------
1 | # YouTube Gallery
2 |
3 | Youtube has an incredible recommendation system which helps people to find content that they are interested in. There are ways that Wildcard can help with spending your time on the platform (shown below).
4 |
5 |
6 |
7 |
8 |
Youtube Default View with Wildcard open.
9 |
10 |
11 |
Wildcard can be used to sort the videos based on their length.
12 |
13 |
14 |
15 |
❮
16 |
❯
17 |
18 |
19 |
20 |
21 |
--------------------------------------------------------------------------------
/docs/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | wildcard - wildcard extension builder
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
66 |
67 |
68 |
70 |
71 |
72 |
73 |
--------------------------------------------------------------------------------
/docs/quickstart.md:
--------------------------------------------------------------------------------
1 | # Quick start
2 |
3 | These instructions are the simplest way to get the Wildcard extension
4 | installed in your browser, in just a few minutes.
5 |
6 | You'll be able to use Wildcard with any website that already has built-in support: Airbnb, Amazon, Hacker News, Instacart, Youtube, and more. See the [Examples Gallery](examples.md) for a full list of supported sites.
7 |
8 | ?> **Note to programmers:** If you know how to program in Javascript, you can write your own bits of scraper code to adapt Wildcard to new websites. If you're interested in doing that in the future, you should skip these steps and follow the slightly more complicated [Dev Install](devenv.md) instructions. (If you're not sure which way to go, you can always try this simple install first and then switch to the dev install later if needed.)
9 |
10 | ## 1. Download latest release
11 |
12 | Download the latest Wildcard release as a zip file from the [releases page](https://github.com/geoffreylitt/wildcard/releases). Unzip it to a folder on your computer.
13 |
14 | ## 2. Install in your browser
15 |
16 | **GOOGLE CHROME**: To install the directory as an unpacked Chrome extension:
17 |
18 | 1. Open the Extension Management page by navigating to **chrome://extensions.**
19 | 2. Enable Developer Mode by clicking the toggle switch next to Developer mode.
20 | 3. Click the LOAD UNPACKED button and select the **manifest.json** directory.
21 |
22 | **MICROSOFT EDGE**: To install the directory as an unpacked Edge extension:
23 |
24 | 1. Click on the three dots at the top of your browser.
25 | 2. Next, choose **Extensions** from the context menu as shown below.
26 | 3. When you are on the **Extensions** page, enable the **Developer mode** by enabling the toggle at the bottom left of the page.
27 | 4. Choose the **Load Unpacked option**. This prompts you for a directory where you have your Extension assets. Open the wildcard directory and select the **manifest.json** extension.
28 | 5. After you install your Extension, you may update it by clicking on the Reload button under your Extension listing.
29 |
30 | **MOZILLA FIREFOX**: To install the manifest.json file as an unpacked Firefox extension:
31 |
32 | 1. Open Firefox.
33 | 2. Enter “about:debugging” in the URL bar.
34 | 3. Click “This Firefox”.
35 | 4. Click “Load Temporary Add-on”.
36 | 5. Open the wildcard directory and select the **manifest.json** file.
37 |
38 | Note: You may need to unblock trackers in your Firefox preferences because Wildcard stores data in your browser’s local storage for persistence of changes across page loads.
39 |
40 | Wildcard is most useful when you enable it for all websites.
41 | It'll only activate if there's actually a site adapter
42 | available for that website.
43 | If you want, you can also change the extension settings in your browser
44 | to only activate it on certain sites, or when you click the icon.
45 |
46 | ## 3. Test it out!
47 |
48 | Navigate to [Hacker News](https://news.ycombinator.com/).
49 |
50 | You should see a button in the bottom right corner
51 | prompting you to "Open Wildcard Table". When you click it, you should see a table showing the data in Hacker News.
52 |
53 | Give it a spin:
54 |
55 | * Try sorting the page by one of the columns. When you refresh, it'll still be sorted.
56 | * Right click a column header and click "insert new column" to add a new column. Type in some text, and the corresponding article will be annotated in the page.
57 |
58 | !> If something doesn't work, it's probably a bug in the beta --
59 | please [file a Github issue](https://github.com/geoffreylitt/wildcard/issues) or [email me](mailto:glitt@mit.edu).
60 |
61 | ## Explore
62 |
63 | To get inspiration for what to do with Wildcard,
64 | check out some of the other supported sites and use cases
65 | in the [Example gallery](examples.md).
66 |
67 |
--------------------------------------------------------------------------------
/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "Wildcard",
3 | "version": "0.1",
4 | "description": "Spreadsheet-driven customization of web applications",
5 | "manifest_version": 2,
6 | "content_scripts": [
7 | {
8 | "js": [
9 | "dist/wildcard.js",
10 | "vendor/bootstrap/js/bootstrap.min.js"
11 | ],
12 | "matches": [
13 | ""
14 | ],
15 | "css": [
16 | "dist/wildcard.css",
17 | "vendor/bootstrap/css/bootstrap.min.css"
18 | ]
19 | },
20 | {
21 | "js": [
22 | "dist/marketplace.js"
23 | ],
24 | "matches": [
25 | "https://wildcard-marketplace.herokuapp.com/adapter.html?aid=*",
26 | "https://wildcard-marketplace.herokuapp.com/upload.html?key=*",
27 | "http://wildcard-marketplace.herokuapp.com/adapter.html?aid=*",
28 | "http://wildcard-marketplace.herokuapp.com/upload.html?key=*",
29 | "http://localhost:3000/adapter.html?aid=*",
30 | "http://localhost:3000/upload.html?key=*"
31 | ]
32 | }
33 | ],
34 | "background": {
35 | "scripts": [
36 | "dist/wildcard-background.js",
37 | "src/wildcard-ajax.js"
38 | ]
39 | },
40 | "permissions": [
41 | "storage",
42 | "*://localhost/*",
43 | "history",
44 | "http://*/*",
45 | "https://*/*",
46 | "webRequest",
47 | "webRequestBlocking",
48 | "",
49 | "contextMenus"
50 | ],
51 | "options_page": "options.html"
52 | }
--------------------------------------------------------------------------------
/options.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Wildcard Extension Options
5 |
6 |
7 |
8 |
34 |
35 |
36 | Wildcard Options
37 |
38 |
39 | Choose an action:
40 |
41 | Choose action
42 | Create Local Adapter
43 | Delete Local Adapter
44 | Load Local Adapter
45 |
46 |
47 |
48 | Create an adapter:
49 |
50 |
51 |
52 | Choose an adapter:
53 |
54 | Choose adapter
55 |
56 |
57 |
58 |
59 |
60 | Save Adapter
61 | Delete Adapter
62 |
63 |
64 |
65 |
66 |
--------------------------------------------------------------------------------
/options.js:
--------------------------------------------------------------------------------
1 | (function(){
2 | let LOCAL_ADAPTERS;
3 | const localStorageKey = 'localStorageAdapter';
4 | const localAdaptersKey = `${localStorageKey}:adapters`;
5 | const adapterActionsSelect = document.getElementById('adapterActions');
6 | const createAdaptersContainer = document.getElementById('createAdapterContainer');
7 | const createAdaptersInput = document.getElementById('createAdapter');
8 | const loadAdaptersContainer = document.getElementById('loadAdaptersContainer');
9 | const adaptersSelect = document.getElementById('loadAdapters');
10 | const saveAdapterButton = document.getElementById('saveAdapter');
11 | const deleteAdapterButton = document.getElementById('deleteAdapter');
12 | var editor;
13 |
14 | window.onload = function(e){
15 | editor = ace.edit("adapterEditor");
16 | editor.session.setMode("ace/mode/typescript");
17 | editor.setTheme("ace/theme/monokai");
18 | }
19 |
20 | function statusMessage(msg) {
21 | let status = document.getElementById('status');
22 | status.textContent = msg;
23 | setTimeout(function() {
24 | status.textContent = '';
25 | }, 1000);
26 | }
27 | function readFromLocalStorage(key, callback) {
28 | chrome.storage.local.get([key], (results) => {
29 | callback(results[key])
30 | });
31 | }
32 | function saveToLocalStorage(key, value, callback) {
33 | if (callback) {
34 | chrome.storage.local.set({ [key]: value }, callback);
35 | } else {
36 | chrome.storage.local.set({ [key]: value }, function() {
37 | // Update status to let user know options were saved.
38 | statusMessage("Adapter saved.");
39 | });
40 | }
41 | }
42 | function removeFromLocalStorage(key, callback) {
43 | if (callback) {
44 | chrome.storage.local.remove(key, callback);
45 | } else {
46 | chrome.storage.local.remove(key, function() {
47 | // Update status to let user know options were saved.
48 | statusMessage("Options updated.");
49 | });
50 | }
51 | }
52 | function populateAdapterSelect(localAdapters = []) {
53 | LOCAL_ADAPTERS = localAdapters;
54 | adaptersSelect.innerHTML = '';
55 | const defaultOption = document.createElement('option');
56 | defaultOption.text = 'Choose adapter';
57 | defaultOption.value = '';
58 | defaultOption.selected = true;
59 | defaultOption.disabled = true;
60 | defaultOption.hidden = true;
61 | adaptersSelect.append(defaultOption);
62 | localAdapters.forEach(adapter => {
63 | const option = document.createElement('option');
64 | option.value = adapter;
65 | option.text = adapter;
66 | adaptersSelect.append(option);
67 | });
68 | }
69 | adapterActionsSelect.addEventListener('change', () => {
70 | const action = adapterActionsSelect.value;
71 | loadAdaptersContainer.style.display = 'none';
72 | createAdaptersContainer.style.display = 'none';
73 | deleteAdapterButton.style.display = 'none';
74 | createAdaptersInput.value = '';
75 | editor.setValue('');
76 | if (action === 'create') {
77 | createAdaptersContainer.style.display = 'block';
78 | } else if (action === 'load' || action === 'delete') {
79 | loadAdaptersContainer.style.display = 'block';
80 | const adapter = adaptersSelect.value;
81 | if (adapter) {
82 | readFromLocalStorage(`${localAdaptersKey}:${adapter}`, (adapterConfig) => {
83 | editor.setValue(adapterConfig);
84 | });
85 | }
86 | if (action === 'delete') {
87 | deleteAdapterButton.style.display = 'inline';
88 | }
89 | }
90 | });
91 | adaptersSelect.addEventListener('change', () => {
92 | const adapter = adaptersSelect.value;
93 | readFromLocalStorage(`${localAdaptersKey}:${adapter}`, (adapterConfig) => {
94 | editor.setValue(adapterConfig);
95 | });
96 | });
97 | saveAdapterButton.addEventListener('click', () => {
98 | const adapterConfig = editor.getValue().trim();
99 | const action = adapterActionsSelect.value;
100 | const adapter = action === 'create' ? createAdaptersInput.value.trim() : adaptersSelect.value;
101 | if (adapter && adapterConfig) {
102 | if (Array.isArray(LOCAL_ADAPTERS) && LOCAL_ADAPTERS.indexOf(adapter) === -1) {
103 | LOCAL_ADAPTERS.push(adapter);
104 | populateAdapterSelect(LOCAL_ADAPTERS);
105 | saveToLocalStorage(localAdaptersKey, LOCAL_ADAPTERS);
106 | }
107 | saveToLocalStorage(`${localAdaptersKey}:${adapter}`, adapterConfig);
108 | } else {
109 | alert('Please select an adapter to save to and make sure the text area has content.')
110 | }
111 | });
112 | deleteAdapterButton.addEventListener('click', () => {
113 | const adapter = adaptersSelect.value;
114 | if (adapter) {
115 | if (Array.isArray(LOCAL_ADAPTERS) && LOCAL_ADAPTERS.indexOf(adapter) !== -1) {
116 | LOCAL_ADAPTERS.splice(LOCAL_ADAPTERS.indexOf(adapter), 1);
117 | populateAdapterSelect(LOCAL_ADAPTERS);
118 | saveToLocalStorage(localAdaptersKey, LOCAL_ADAPTERS);
119 | }
120 | removeFromLocalStorage(`${localAdaptersKey}:${adapter}`, () => {
121 | editor.setValue('');
122 | statusMessage("Adapter removed.");
123 | });
124 |
125 | }
126 | });
127 | chrome.runtime.getBackgroundPage((backgroundPage) => {
128 | readFromLocalStorage(localAdaptersKey, populateAdapterSelect);
129 | const { state } = backgroundPage;
130 | if (state && state.endUserScraper) {
131 | const { name, config } = state.endUserScraper;
132 | adapterActionsSelect.value = 'create';
133 | adapterActionsSelect.dispatchEvent(new Event('change'));
134 | createAdaptersInput.value = name;
135 | editor.setValue(config);
136 | delete state.endUserScraper;
137 | }
138 | });
139 | })();
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "wildcard",
3 | "version": "1.0.0",
4 | "description": "wildcard extension builder",
5 | "main": "index.js",
6 | "repository": "git@github.com:geoffreylitt/wildcard.git",
7 | "author": "Geoffrey Litt ",
8 | "license": "MIT",
9 | "dependencies": {
10 | "@ckeditor/ckeditor5-build-classic": "^16.0.0",
11 | "@fullcalendar/core": "^4.3.1",
12 | "@fullcalendar/daygrid": "^4.3.0",
13 | "@fullcalendar/google-calendar": "^4.4.0",
14 | "@fullcalendar/interaction": "^4.3.0",
15 | "@fullcalendar/moment": "^4.3.0",
16 | "@handsontable/react": "^3.1.2",
17 | "@types/chrome": "^0.0.95",
18 | "@types/greasemonkey": "^4.0.0",
19 | "@types/jquery": "^3.3.33",
20 | "docsify-cli": "^4.4.0",
21 | "handsontable": "^7.2.2",
22 | "lodash": "^4.17.15",
23 | "moment": "^2.24.0",
24 | "ohm-js": "^0.14.0",
25 | "palettify": "^1.0.3",
26 | "pikaday": "^1.8.0",
27 | "react": "^16.13.1",
28 | "react-ace": "^9.1.4",
29 | "react-autosuggest": "^10.1.0",
30 | "react-dom": "^16.13.1",
31 | "react-redux": "^7.2.0",
32 | "redux": "^4.0.5",
33 | "redux-thunk": "^2.3.0",
34 | "reselect": "^4.0.0",
35 | "rollup-plugin-node-builtins": "^2.1.2",
36 | "string-hash": "^1.1.3",
37 | "styled-components": "^5.1.0",
38 | "unfluff": "^3.2.0",
39 | "url-pattern": "^1.0.3"
40 | },
41 | "scripts": {
42 | "docs": "docsify serve docs",
43 | "dev": "node ./build.js -watch",
44 | "chrome": "web-ext run --target chromium --keep-profile-changes",
45 | "firefox": "web-ext run --keep-profile-changes"
46 | },
47 | "devDependencies": {
48 | "@types/lodash": "^4.14.168",
49 | "@types/react": "^16.9.34",
50 | "@types/react-dom": "^16.9.6",
51 | "@types/redux-thunk": "^2.1.0",
52 | "concurrently": "^5.1.0",
53 | "docsify": "^4.11.3",
54 | "esbuild": "^0.8.0",
55 | "estrella": "^1.3.1",
56 | "random-words": "^1.1.1",
57 | "redux-devtools-extension": "^2.13.8",
58 | "socket.io": "^2.3.0",
59 | "typescript": "^3.7.4",
60 | "watch": "^1.0.2",
61 | "web-ext": "^5.5.0"
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/readme-resources/architecture-v02.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/geoffreylitt/wildcard/dd81d9b4a34729887e86b2e5de0c3e8ecdc94b03/readme-resources/architecture-v02.png
--------------------------------------------------------------------------------
/resources/adapter-configs/eecscoursecatalog.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "MIT EECS Course Catalog",
3 | "urls": [
4 | "http://eecs.scripts.mit.edu/eduportal/who_is_teaching_what/F/2020"
5 | ],
6 | "matches": [
7 | "http://eecs.scripts.mit.edu/eduportal/who_is_teaching_what/F/2020"
8 | ],
9 | "attributes": [
10 | {
11 | "name": "id",
12 | "type": "text",
13 | "hidden": true
14 | },
15 | {
16 | "name": "number",
17 | "type": "text"
18 | },
19 | {
20 | "name": "title",
21 | "type": "text"
22 | },
23 | {
24 | "name": "mode",
25 | "type": "text"
26 | },
27 | {
28 | "name": "lecturers",
29 | "type": "text"
30 | },
31 | {
32 | "name": "recitation instructors",
33 | "type": "text"
34 | }
35 | ],
36 | "scrapePage": "() => {\n return Array.from(document.querySelectorAll('tr[id]')).map(el => {\n let courseData = el.getElementsByTagName('td')\n let courseNumber = el.id\n let courseName = courseData[0].innerText\n\n return {\n id: courseNumber,\n rowElements: [el],\n dataValues: {\n number: courseNumber,\n title: courseName.substring(courseNumber.length + 1),\n mode: courseData[1].innerText,\n lecturers: courseData[2].innerText,\n 'recitation instructors': courseData[3].innerText\n }\n }\n })\n }",
37 | "onRowSelected": "(row) => {\n row.rowElements.forEach(el => {\n if (el.style) {\n el.style['background-color'] = '#c9ebff';\n }\n });\n row.rowElements[0].scrollIntoView({ behavior: 'smooth', block: 'center' });\n }",
38 | "onRowUnselected": "(row) => {\n row.rowElements.forEach(el => {\n if (el.style) {\n el.style['background-color'] = '';\n }\n })\n }"
39 | }
--------------------------------------------------------------------------------
/resources/adapter-configs/github.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "Github",
3 | "urls": [
4 | "https://github.com/geoffreylitt?tab=repositories"
5 | ],
6 | "matches": [
7 | "github.com.*tab=repositories"
8 | ],
9 | "attributes": [
10 | {
11 | "name": "name",
12 | "type": "text"
13 | },
14 | {
15 | "name": "stars",
16 | "type": "numeric"
17 | },
18 | {
19 | "name": "forks",
20 | "type": "text"
21 | },
22 | {
23 | "name": "updated",
24 | "type": "text"
25 | }
26 | ],
27 | "scrapePage": "() => {\n return Array.from(document.querySelectorAll(\"li.source\")).map(el => {\n let name_el = el.querySelector('a[itemprop=\"name codeRepository\"]')\n let name = name_el.textContent.trim()\n\n let stars_el = el.querySelector('*[href*=\"/stargazers\"')\n let stars = extractNumber(stars_el, 0)\n\n let forks_el = el.querySelector('*[href*=\"/network/members\"]')\n let forks = extractNumber(forks_el, 0)\n\n let lang_el = el.querySelector('*[itemprop=\"programmingLanguage\"]')\n // some repos don't have language set\n let lang = lang_el == null ? null : lang_el.textContent.trim()\n\n let updated_el = el.querySelector('relative-time')\n let updated = updated_el.getAttribute('datetime')\n\n return {\n id: name,\n rowElements: [el],\n dataValues: {\n name: name,\n stars: stars,\n forks: forks,\n updated: updated,\n }\n }\n })\n }",
28 | "onRowSelected": "(row) => {\n row.rowElements.forEach(el => {\n if (el.style) {\n el.style[\"background-color\"] = \"#def3ff\"\n }\n });\n row.rowElements[0].scrollIntoView({ behavior: \"smooth\", block: \"center\" })\n }",
29 | "onRowUnselected": "(row) => {\n row.rowElements.forEach(el => {\n if(el.style) {\n el.style[\"background-color\"] = \"\";\n }\n })\n }"
30 | }
--------------------------------------------------------------------------------
/resources/adapter-configs/hackernews.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "Hacker News",
3 | "urls": [
4 | "news.ycombinator.com"
5 | ],
6 | "matches": [
7 | "news.ycombinator.com",
8 | "news.ycombinator.com/news",
9 | "news.ycombinator.com/newest"
10 | ],
11 | "attributes": [
12 | {
13 | "name": "id",
14 | "type": "text",
15 | "hidden": true
16 | },
17 | {
18 | "name": "rank",
19 | "type": "numeric"
20 | },
21 | {
22 | "name": "title",
23 | "type": "text"
24 | },
25 | {
26 | "name": "link",
27 | "type": "text"
28 | },
29 | {
30 | "name": "points",
31 | "type": "numeric"
32 | },
33 | {
34 | "name": "user",
35 | "type": "text"
36 | },
37 | {
38 | "name": "comments",
39 | "type": "numeric"
40 | }
41 | ],
42 | "scrapePage": "() => {\n return Array.from(document.querySelectorAll(\"tr.athing\")).map(el => {\n let detailsRow = el.nextElementSibling\n let spacerRow = detailsRow.nextElementSibling\n\n return {\n id: String(el.getAttribute(\"id\")),\n rowElements: [el, detailsRow, spacerRow],\n // todo: Both of these steps should be handled by the framework...\n // .filter(e => e) // Only include if the element is really there\n // .map(e => (e)), // Convert to HTMLElement type\n dataValues: {\n rank: el.querySelector(\"span.rank\"),\n title: el.querySelector(\"a.storylink\"),\n link: el.querySelector(\"a.storylink\").getAttribute(\"href\"),\n // These elements contain text like \"162 points\";\n // Wildcard takes care of extracting a number automatically.\n points: detailsRow.querySelector(\"span.score\"),\n user: detailsRow.querySelector(\"a.hnuser\"),\n comments: extractNumber(Array.from(detailsRow.querySelectorAll(\"a\"))\n .find(e => e.textContent.indexOf(\"comment\") !== -1), 0)\n },\n annotationContainer: detailsRow.querySelector(\"td.subtext\"),\n annotationTemplate: \"| $annotation \"\n }\n })\n }",
43 | "onRowSelected": "(row) => {\n row.rowElements.forEach(el => {\n if (el.style) {\n el.style[\"background-color\"] = \"#def3ff\";\n }\n });\n row.rowElements[0].scrollIntoView({ behavior: \"smooth\", block: \"center\" });\n }",
44 | "onRowUnselected": "(row) => {\n row.rowElements.forEach(el => {\n if(el.style) {\n el.style[\"background-color\"] = \"\";\n }\n })\n }"
45 | }
--------------------------------------------------------------------------------
/resources/adapter-configs/mitcoursecatalog.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "MIT Course Catalog",
3 | "urls": [
4 | "http://student.mit.edu/catalog/m6c.html"
5 | ],
6 | "matches": [
7 | "http://student.mit.edu/catalog"
8 | ],
9 | "attributes": [
10 | {
11 | "name": "id",
12 | "type": "text",
13 | "hidden": true
14 | },
15 | {
16 | "name": "code",
17 | "type": "text"
18 | },
19 | {
20 | "name": "name",
21 | "type": "text"
22 | },
23 | {
24 | "name": "level",
25 | "type": "text"
26 | }
27 | ],
28 | "scrapePage": "() => {\n return Array.from(document.querySelectorAll('h3'))\n .slice(1)\n .filter((element) => {\n const regEx = new RegExp('[A-Z0-9]+\\\\.[0-9]+(\\\\[[A-Z]\\])?\\\\s.+');\n const matches = regEx.test(element.textContent);\n return matches;\n })\n .map((element) => {\n const regEx = new RegExp('([A-Z0-9]+\\\\.[0-9]+(\\\\[[A-Z]\\\\])?)\\\\s(.+)');\n const matches = regEx.exec(element.textContent.trim());\n const match = matches[0];\n const id = matches[1];\n const symbol = matches[2]; \n const name = matches[3];\n const rowElements = [element];\n let currentElement = element.nextSibling;\n while (currentElement && currentElement.tagName !== \"H3\") {\n rowElements.push(currentElement);\n currentElement = currentElement.nextSibling;\n }\n let level;\n for (var i = 1; i < rowElements.length; i++) {\n var rowElement = rowElements[i];\n if (rowElement.tagName === 'IMG') {\n if (rowElement.title === 'Undergrad') {\n level = 'U';\n break;\n }\n else if (rowElement.title === 'Graduate') {\n level = 'G';\n break;\n }\n }\n }\n const row = {\n id: id,\n rowElements: rowElements,\n dataValues: {\n code: id,\n name: name,\n level: level\n },\n annotationContainer: rowElements[rowElements.length - 1],\n annotationTemplate: '$annotation '\n };\n return row;\n });\n }\n ",
29 | "onRowSelected": "(row) => {\n const rowElement = row.rowElements[0];\n rowElement.style.border = \"solid 2px #b12b28\";\n rowElement.scrollIntoView({ behavior: \"smooth\", block: \"center\" });\n }",
30 | "onRowUnselected": "(row) => {\n const rowElement = row.rowElements[0];\n rowElement.style.border = '';\n }"
31 | }
--------------------------------------------------------------------------------
/resources/adapter-configs/youtube.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "YouTube",
3 | "urls": [
4 | "youtube.com"
5 | ],
6 | "matches": [
7 | "youtube.com"
8 | ],
9 | "attributes": [
10 | {
11 | "name": "id",
12 | "type": "text",
13 | "hidden": true
14 | },
15 | {
16 | "name": "Title",
17 | "type": "text"
18 | },
19 | {
20 | "name": "Time",
21 | "type": "text"
22 | },
23 | {
24 | "name": "Uploader",
25 | "type": "text"
26 | },
27 | {
28 | "name": "% Watched",
29 | "type": "numeric"
30 | }
31 | ],
32 | "scrapePage": "() => {\n function progressToNumber(progress){\n let strippedProgress = progress.slice(0, -1);\n return parseInt(strippedProgress);\n }\n let tableRows = document.querySelector('#contents').children;\n if (tableRows.length == 1) {\n // for use on video listing page e.g. https://www.youtube.com/user/*/videos\n tableRows = document.querySelector('#contents #items').children;\n }\n return Array.from(tableRows).map((el, index) => {\n let elAsHTMLElement = el;\n\n // on /user/*/videos, link is in #thumbnail, not #video-title-link\n if((el.querySelector('#video-title-link') !== null || el.querySelector('#thumbnail') !== null) && el.querySelector('#overlays') != null && el.querySelector('#overlays').children[0] != null){\n\n let overlayChildrenAmount = el.querySelector('#overlays').children.length;\n let timeStampExists = overlayChildrenAmount > 1 && el.querySelector('#overlays').children[overlayChildrenAmount - 2].children[1] !== undefined;\n let timeStamp = timeStampExists\n ? el.querySelector('#overlays').children[overlayChildrenAmount - 2].children[1].textContent.replace(new RegExp(\"|\\\\r\\\\n|\\\\n|\\\\r\", \"gm\"),\"\")\n : \"N/A\";\n let watchedPercentage = el.querySelector('#progress') !== null\n ? progressToNumber((el.querySelector('#progress')).style.width)\n : 0;\n\n return {\n rowElements: [elAsHTMLElement],\n id: (el.querySelector('#video-title-link') || el.querySelector('#thumbnail')).getAttribute(\"href\"),\n dataValues: {\n Title: el.querySelector('#video-title'),\n Time: timeStamp,\n Uploader: el.querySelector('#text').children[0],\n '% Watched': watchedPercentage,\n },\n }\n } else {\n return null;\n }\n }).filter(el => el !== null)\n }",
33 | "addScrapeTriggers": "(reload) => {\n document.addEventListener(\"click\", (e) => { reload() });\n document.addEventListener(\"keydown\", (e) => { reload() });\n document.addEventListener(\"scroll\", debounce((e) => { reload() }, 50));\n }",
34 | "onRowSelected": "(row) => {\n row.rowElements.forEach(el => {\n if (el.style) {\n el.style[\"background-color\"] = \"#c9ebff\";\n }\n });\n row.rowElements[0].scrollIntoView({ behavior: \"smooth\", block: \"center\" });\n }",
35 | "onRowUnselected": "(row) => {\n row.rowElements.forEach(el => {\n if (el.style) {\n el.style[\"background-color\"] = \"\";\n }\n });\n }"
36 | }
--------------------------------------------------------------------------------
/src/core/actions.ts:
--------------------------------------------------------------------------------
1 |
2 | // todo: define TS types for these events
3 | // https://redux.js.org/recipes/usage-with-typescript
4 |
5 | // Many of these actions don't directly affect the Redux state,
6 | // instead they ask a TableAdapter to do something async,
7 | // which will update the redux state on completion.
8 | // We use async redux-thunk action creators for this.
9 |
10 | import { Table, TableAdapter, tableId, recordId, RecordEdit, Record, Attribute } from './types'
11 | import groupBy from 'lodash/groupBy'
12 | import forIn from 'lodash/forIn'
13 | import pick from 'lodash/pick'
14 | import { getFinalAttributes, getFinalRecords } from './getFinalTable'
15 | import { evalFormulas } from '../formula'
16 |
17 | export const initializeActions = (TableAdapters:{ [key: string]: TableAdapter }) => {
18 | return {
19 | tableReloaded (table:Table) {
20 | return (dispatch, getState) => {
21 | // load the new data into the UI immediately
22 | dispatch({ type: "TABLE_RELOADED", table })
23 |
24 | // asynchronously trigger formula re-evaluation
25 | const state = getState()
26 | const finalRecords:Record[] = getFinalRecords(state)
27 | const finalAttributes:Attribute[] = getFinalAttributes(state)
28 |
29 | evalFormulas(finalRecords, finalAttributes, (values) => {
30 | dispatch({ type: "FORMULAS_EVALUATED", values })
31 | })
32 | }
33 | },
34 |
35 | addAttribute (tableId:tableId) {
36 | return (dispatch) => {
37 | dispatch({
38 | type: "ADD_ATTRIBUTE_REQUESTED"
39 | })
40 | const TableAdapter = TableAdapters[tableId];
41 | TableAdapter.addAttribute().then(
42 | // no need to do anything, since we're already subscribed to reloads
43 | (_table) => { },
44 | (err) => { console.error(err) }
45 | )
46 | }
47 | },
48 |
49 | clear (tableId:tableId) {
50 | return (dispatch) => {
51 | const TableAdapter = TableAdapters[tableId];
52 | TableAdapter.clear()
53 | }
54 | },
55 |
56 | toggleVisibility (tableId:tableId, colName:string) {
57 | return (dispatch) => {
58 | dispatch({
59 | type: "HIDE_COL_REQUESTED"
60 | })
61 | const TableAdapter = TableAdapters[tableId];
62 | TableAdapter.toggleVisibility(colName);
63 | }
64 | },
65 |
66 | setFormula (tableId:tableId, attrName:string, formula) {
67 | return (dispatch) => {
68 | const TableAdapter = TableAdapters[tableId];
69 | TableAdapter.setFormula(attrName, formula);
70 | }
71 | },
72 |
73 | editRecords(edits) {
74 | return (dispatch) => {
75 | dispatch({ type: "EDIT_RECORDS_REQUESTED", edits })
76 |
77 | // split up the request edits by table, and ask each table to
78 | // do its part of the edits
79 |
80 | const editsByTable = groupBy(edits, e => e.tableId);
81 |
82 | forIn(editsByTable, (edits, tableId) => {
83 | const TableAdapter = TableAdapters[tableId];
84 | const editsForTable:Array = edits.map(e => pick(e, "recordId", "attribute", "value"))
85 | TableAdapter.editRecords(editsForTable);
86 | });
87 | }
88 | },
89 |
90 | sortRecords (sortConfig) {
91 | return { type: "SORT_RECORDS", sortConfig }
92 | },
93 |
94 | selectRecord (recordId, attribute) {
95 | return { type: "RECORD_SELECTED", recordId, attribute }
96 | }
97 | }
98 | }
99 |
--------------------------------------------------------------------------------
/src/core/debug.tsx:
--------------------------------------------------------------------------------
1 | import { getFinalRecords, getFinalAttributes } from './getFinalTable'
2 |
3 | // this middleware sends redux information to the console.
4 | // it would be better to get Redux Dev Tools working, but for some reaosn
5 | // Redux Dev Tools doesn't currently work for this extension, so we roll our own
6 | export const debugMiddleware = ({ getState }) => next => action => {
7 | //console.debug('will dispatch', action)
8 |
9 | // Call the next dispatch method in the middleware chain.
10 | const returnValue = next(action)
11 |
12 | // todo: this is where we're going to update the state in the extension.
13 | // (for now, we don't need to do it because the table lives inside the app)
14 | //console.debug('state after dispatch', getState())
15 |
16 | return returnValue
17 | }
18 |
--------------------------------------------------------------------------------
/src/core/getFinalTable.ts:
--------------------------------------------------------------------------------
1 | // This file contains the logic for assembling a final table to display:
2 | //
3 | // * Join together various tables -- app data, user data
4 | // * Join together attribute lists -- app, user
5 | // * Sort / filter the final output
6 |
7 | // These are implemented as reselect selectors because they're derived state;
8 | // no need to store in the redux store; just a pure function of the various
9 | // tables and attributes.
10 |
11 | // Whatever gets outputted by these selectors is *exactly* what gets displayed
12 | // in the final table view.
13 |
14 |
15 | import { createSelector } from 'reselect';
16 | import sortBy from 'lodash/sortBy';
17 | import keyBy from 'lodash/keyBy';
18 | import { RootState } from './reducer';
19 | import { Attribute, Record, SortConfig } from './types';
20 |
21 | const getAppRecords = (state:RootState):Record[] => state.appTable.records
22 | const getAppAttributes = (state:RootState): Attribute[] => state.appTable.attributes
23 |
24 | const getUserRecords = (state:RootState):Record[] => state.userTable.records
25 | const getUserAttributes = (state:RootState):Attribute[] => state.userTable.attributes
26 |
27 | const getSortConfig = (state:RootState):SortConfig => state.query.sortConfig
28 | const getFormulaResults = (state:RootState):any => state.formulaResults
29 |
30 | export const getFinalAttributes = createSelector(
31 | [getAppAttributes, getUserAttributes, getFormulaResults],
32 | (appAttributes, userAttributes, formulaResults) => {
33 | // annotate attrs with a table id
34 | appAttributes = (appAttributes || []).map( a => ({ ...a, tableId: "app" }))
35 | userAttributes = (userAttributes || []).map(a => ({ ...a, tableId: "user" }))
36 |
37 | const attributes = appAttributes.concat(userAttributes)
38 |
39 | // set column type for formulas based on first row
40 | const formulaResultsKeys = Object.keys(formulaResults);
41 | if (formulaResultsKeys.length > 0) {
42 | attributes.forEach(attr => {
43 | if(attr.formula) {
44 | const sampleValue = formulaResults[formulaResultsKeys[0]][attr.name]
45 | if(typeof sampleValue === 'number') {
46 | attr.type = "numeric"
47 | } else if (typeof sampleValue === 'boolean') {
48 | attr.type = "checkbox"
49 | } else if (sampleValue instanceof HTMLElement) {
50 | // hmm.. is this sensible...
51 | attr.type = "element"
52 | } else {
53 | attr.type = "text"
54 | }
55 | } else {
56 | attr.type = "text"
57 | }
58 | })
59 | }
60 | return attributes
61 | }
62 | )
63 |
64 | // todo: this selector is just cached on the whole state --
65 | // probably pointless to use this selector concept here?
66 | export const getFinalRecords = createSelector(
67 | [getAppRecords, getUserRecords, getAppAttributes, getUserAttributes, getSortConfig, getFormulaResults, getFinalAttributes],
68 | (appRecords, userRecords, appAttributes, userAttributes, sortConfig, formulaResults, finalAttributes) => {
69 |
70 | const userRecordsById = keyBy(userRecords, r => r.id);
71 |
72 | let finalRecords = appRecords.slice().map(r => {
73 | // join app records to user records
74 | const finalRecord = {
75 | id: r.id,
76 | values: {
77 | ...r.values,
78 | ...(userRecordsById[r.id] || {}).values
79 | }
80 | }
81 |
82 | // add formula results to the table, where available.
83 | // (any missing results are still in process of being computed,
84 | // and we'll re-run the reducer once they are available)
85 | finalAttributes.filter(attr => attr.formula).forEach(attr => {
86 | const result = formulaResults?.[finalRecord.id]?.[attr.name]
87 | if(result !== undefined) {
88 | finalRecord.values[attr.name] = result
89 | }
90 | })
91 |
92 | return finalRecord
93 | })
94 |
95 | // sort
96 | if (sortConfig) {
97 | finalRecords = sortBy(finalRecords, r => r.values[sortConfig.attribute])
98 |
99 | if (sortConfig.direction === "desc") {
100 | finalRecords = finalRecords.reverse()
101 | }
102 | }
103 |
104 | return finalRecords;
105 | }
106 | )
107 |
--------------------------------------------------------------------------------
/src/core/reducer.ts:
--------------------------------------------------------------------------------
1 | import { Record, Attribute, QueryState, Table} from './types'
2 | import includes from 'lodash/includes'
3 | import pick from 'lodash/pick'
4 | import { combineReducers } from 'redux'
5 |
6 | const createTableReducer = (tableId) => {
7 | const initialTable = {
8 | // todo: init this from the site adapter with a site name?
9 | tableId: tableId,
10 | attributes: [],
11 | records: [],
12 | }
13 |
14 | return (state = initialTable, action):Table => {
15 | if (!action.table || action.table.tableId !== tableId) {
16 | return state;
17 | }
18 |
19 | switch(action.type) {
20 | case "TABLE_RELOADED":
21 | return {
22 | ...state,
23 | attributes: [...action.table.attributes],
24 | records: [...action.table.records],
25 | }
26 |
27 | default:
28 | return state;
29 | }
30 | }
31 | }
32 |
33 | const query = (state = { sortConfig: null }, action):QueryState => {
34 | switch(action.type) {
35 | case "SORT_RECORDS":
36 | return {
37 | ...state,
38 | sortConfig: action.sortConfig
39 | }
40 |
41 | default:
42 | return state;
43 | }
44 | }
45 |
46 | const formulaResults = (state = { }, action) => {
47 | switch(action.type) {
48 | case "FORMULAS_EVALUATED":
49 | return {
50 | ...state,
51 | ...action.values
52 | }
53 |
54 | default:
55 | return state;
56 | }
57 | }
58 |
59 | const rootReducer = combineReducers({
60 | appTable: createTableReducer("app"),
61 | userTable: createTableReducer("user"),
62 | query,
63 | formulaResults
64 | })
65 |
66 | export type RootState = ReturnType
67 |
68 | export default rootReducer;
69 |
--------------------------------------------------------------------------------
/src/core/types.ts:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | import {
4 | ScrapedRow,
5 | ScrapingAdapterConfig
6 | } from '../site_adapters/domScrapingBase'
7 |
8 | // This file defines types used throughout Wildcard
9 |
10 | type id = string;
11 |
12 | export type recordId = id;
13 | export type tableId = id;
14 |
15 | export interface Record {
16 | id: recordId;
17 | values: any;
18 | }
19 |
20 | export interface RecordEdit {
21 | recordId:recordId;
22 | attribute:string;
23 | value:any;
24 | }
25 |
26 |
27 | /**
28 | * Defines the schema for one column of the table being extracted.
29 | */
30 | export interface Attribute {
31 | /** The name of this data column, to be displayed in the table */
32 | name: string;
33 |
34 | /** The type of this column. Can be any
35 | * [Handsontable cell type](https://handsontable.com/docs/7.3.0/tutorial-cell-types.html).
36 | * Examples: text, numeric, date, checkbox. */
37 | type: string;
38 |
39 | /** Allow user to edit this value? Defaults to false.
40 | * Making a column editable requires extracting [[PageValue]]s as Elements.*/
41 | editable?: boolean;
42 |
43 | /** The formula for computing this cell. (If missing, cell is considered manual data) */
44 | formula?: string;
45 |
46 | /** Specify a custom [Handsontable editor](https://handsontable.com/docs/7.3.0/tutorial-cell-editor.html)
47 | * as a class (see Expedia adapter for an example) */
48 | editor?: string;
49 |
50 | /** Specify a custom [Handsontable rendererr](https://handsontable.com/docs/7.3.0/demo-custom-renderers.html)
51 | * as a class (todo: not actually supported yet, but will be soon ) */
52 | renderer?: string;
53 |
54 | /** Hide this column in the visible table?
55 | Eg, useful for hiding an ID column that's needed for sorting */
56 | hidden?: boolean;
57 |
58 | // todo: move these into a metadata sub-object or something
59 | timeFormat?: string;
60 | correctFormat?: boolean;
61 |
62 | hideInPage?: boolean;
63 | }
64 |
65 |
66 | export interface Table {
67 | tableId: tableId;
68 | attributes: Array;
69 | records: Array
70 | }
71 |
72 | export interface SortConfig {
73 | attribute: string;
74 | direction: "asc" | "desc";
75 | }
76 |
77 | export interface QueryState {
78 | sortConfig: SortConfig
79 | }
80 |
81 | export type TableCallback = (table:Table) => void;
82 |
83 | // Generalizing over the site adapters and user data, among others
84 | export interface TableAdapter {
85 | tableId: tableId;
86 |
87 | name:string
88 |
89 | /** return true if this adapter should be enabled for this site */
90 | enabled():boolean;
91 |
92 | /** start up this TableAdapter */
93 | initialize?(namespace?:string):void;
94 |
95 | // =====================
96 | // Reading data
97 | // =====================
98 |
99 | /** Return latest data */
100 | loadTable():Table;
101 |
102 | /** Register a callback function which will be called with a new table
103 | * of data anytime the data changes. */
104 | subscribe (callback:TableCallback):void;
105 |
106 | // ============================================================
107 | // Requesting to the TableAdapter to modify the table in some way
108 | // ============================================================
109 |
110 | // todo: should probably update these to return promises
111 | // rather than just void and throwing away any return values
112 |
113 | /** Apply a new sort order to the table */
114 | applySort(finalRecords:Array, sortConfig:SortConfig):void;
115 |
116 | editRecords(edits:Array):Promise;
117 |
118 | handleRecordSelected(recordId: recordId, attribute: string);
119 |
120 | /** clear the contents of the table */
121 | clear():void
122 |
123 | addAttribute():Promise;
124 |
125 | toggleVisibility(colName: string):void;
126 |
127 | setFormula(attrName: string, formula: string);
128 |
129 |
130 | /** Update the UI to match arbitrary table state
131 | * (To implement performantly, probably do a diff inside the adapter
132 | * and only update the UI where necessary) */
133 | // update?(table:Table):void;
134 |
135 | // ============================================================
136 | // Notifying the TableAdapter of changes to other tables
137 | // ============================================================
138 |
139 | handleOtherTableUpdated(table:Table):void;
140 |
141 | updateConfig?(config): void;
142 |
143 | getConfig?(): ScrapingAdapterConfig
144 |
145 | }
146 |
--------------------------------------------------------------------------------
/src/end_user_scraper/adapterHelpers.ts:
--------------------------------------------------------------------------------
1 | import {
2 | readFromChromeLocalStorage,
3 | saveToChromeLocalStorage,
4 | removeFromChromeLocalStorage
5 | } from '../utils';
6 |
7 | import {
8 | ADAPTERS_BASE_KEY
9 | } from './constants';
10 | import {
11 | indexToAlpha
12 | } from './utils';
13 |
14 | import {
15 | getCachedActiveAdapter,
16 | setAdapterKey
17 | } from './state';
18 |
19 | import {
20 | createDomScrapingAdapter
21 | } from '../site_adapters/domScrapingBase';
22 |
23 | import { compileAdapterJavascript, userStore } from '../localStorageAdapter';
24 |
25 | export function createAdapterKey() {
26 | return `${ADAPTERS_BASE_KEY}:${_createAdapterId()}`
27 | }
28 |
29 | function _saveAdapter(adapterKey, config, callback?) {
30 | const _config = JSON.stringify(config, null, 2);
31 | if (adapterKey) {
32 | const adapterName = adapterKey.match(/localStorageAdapter\:adapters\:(.*)/)[1]
33 | readFromChromeLocalStorage([ADAPTERS_BASE_KEY])
34 | .then(results => {
35 | let adapters = results[ADAPTERS_BASE_KEY];
36 | if (adapters === undefined) {
37 | adapters = []
38 | }
39 | if (!adapters.includes(adapterName)) {
40 | adapters.push(adapterName);
41 | saveToChromeLocalStorage({
42 | [ADAPTERS_BASE_KEY]: adapters,
43 | [adapterKey]: _config
44 | }).then(() => {
45 | if (callback) {
46 | callback();
47 | }
48 | })
49 | } else {
50 | saveToChromeLocalStorage({ [adapterKey]: _config })
51 | .then(() => {
52 | if (callback) {
53 | callback();
54 | }
55 | });
56 | }
57 | });
58 | }
59 | }
60 |
61 | export function saveAdapter(adapterKey, config, callback?) {
62 | readFromChromeLocalStorage([adapterKey])
63 | .then((results) => {
64 | if (results[adapterKey]) {
65 | const currentConfig = JSON.parse(results[adapterKey]);
66 | if (!adaptersAreIdentical(currentConfig, config)) {
67 | _saveAdapter(
68 | adapterKey,
69 | config,
70 | callback
71 | );
72 | } else if (callback) {
73 | callback();
74 | }
75 | } else {
76 | _saveAdapter(
77 | adapterKey,
78 | config,
79 | callback
80 | );
81 | }
82 | })
83 | }
84 |
85 | export function deleteAdapter(adapterKey, callback) {
86 | if (adapterKey) {
87 | const adapterName = adapterKey.split(':').pop();
88 | readFromChromeLocalStorage([ADAPTERS_BASE_KEY])
89 | .then(results => {
90 | const adapters = results[ADAPTERS_BASE_KEY] as Array;
91 | if (Array.isArray(adapters)) {
92 | const adapterIndex = adapters.indexOf(adapterName);
93 | if (adapterIndex !== -1) {
94 | adapters.splice(adapterIndex, 1);
95 | saveToChromeLocalStorage({ [ADAPTERS_BASE_KEY]: adapters })
96 | .then(() => {
97 | removeFromChromeLocalStorage([adapterKey, `query:${adapterName}`])
98 | .then(() => {
99 | userStore.clear();
100 | callback();
101 | });
102 | });
103 | } else {
104 | callback();
105 | }
106 | } else {
107 | callback();
108 | }
109 | });
110 | } else {
111 | callback();
112 | }
113 | }
114 |
115 | export function generateAdapter(columnSelectors, rowSelector, adapterKey) {
116 | const { attributes, scrapePage } = createAdapterData(rowSelector, columnSelectors);
117 | return {
118 | name: document.title,
119 | urls: [window.location.href],
120 | matches: [`${window.location.origin}${window.location.pathname}`],
121 | attributes,
122 | metadata: {
123 | id: adapterKey,
124 | columnSelectors,
125 | rowSelector
126 | },
127 | scrapePage
128 | };
129 | }
130 |
131 | export function createInitialAdapter() {
132 | const config = createInitialAdapterConfig();
133 | return createDomScrapingAdapter(config as any);
134 | }
135 |
136 | export function createInitialAdapterConfig() {
137 | const adapterKey = createAdapterKey();
138 | setAdapterKey(adapterKey);
139 | const config = generateAdapter([], '', adapterKey);
140 | compileAdapterJavascript(config);
141 | return config;
142 | }
143 |
144 | export function updateAdapter(adapterKey, columnSelectors, rowSelector) {
145 | const activeAdapter = getCachedActiveAdapter();
146 | if (activeAdapter) {
147 | const config = generateAdapter(columnSelectors, rowSelector, adapterKey);
148 | const configCopy = {...config};
149 | compileAdapterJavascript(configCopy);
150 | setTimeout(() => {
151 | activeAdapter.updateConfig(configCopy);
152 | }, 0);
153 | }
154 | }
155 |
156 | function adaptersAreIdentical(adapter1, adapter2) {
157 | if (adapter1.attributes.length !== adapter2.attributes.length) {
158 | return false;
159 | }
160 | if (adapter1.scrapePage !== adapter2.scrapePage) {
161 | return false;
162 | }
163 | for (let i = 0; i < adapter1.attributes.length; i++) {
164 | const adapter1Attribute = adapter1.attributes[i];
165 | const adapter2Attribute = adapter2.attributes[i];
166 | if (adapter1Attribute.formula !== adapter2Attribute.formula) {
167 | return false;
168 | }
169 | }
170 | return true;
171 | }
172 |
173 | function createAdapterData(rowSelector, columnSelectors) {
174 | return {
175 | attributes: _createAttributes({ rowSelector, columnSelectors }),
176 | scrapePage: _createScrapPage({ rowSelector })
177 | }
178 | }
179 |
180 | function _createAttributes({ rowSelector, columnSelectors }) {
181 | const attributes = [];
182 | const domFormulas = ["=QuerySelector", "=GetParent"]
183 | if (rowSelector && columnSelectors && columnSelectors.length) {
184 | // add row element attribute
185 | attributes.push({
186 | name: "rowElement",
187 | type: "element",
188 | hidden: true
189 | });
190 | // add remaining attributes
191 | columnSelectors.forEach((columnSelectorList, index) => {
192 | const columnSelector = columnSelectorList[0];
193 | let type = "element";
194 | let formula;
195 | if (columnSelector && columnSelector.startsWith("=")) {
196 | formula = columnSelector;
197 | if (!domFormulas.find(v => formula.startsWith(v))) {
198 | type = "text";
199 | }
200 | } else if (columnSelector) {
201 | formula = `=QuerySelector(rowElement, "${columnSelector}")`;
202 | } else {
203 | formula = ``;
204 | }
205 | attributes.push({
206 | name: indexToAlpha(index),
207 | type,
208 | formula
209 | });
210 | });
211 | }
212 | return attributes;
213 | }
214 |
215 | function _createScrapPage({ rowSelector }) {
216 | return `() => {
217 | const rowElements = ${!!rowSelector} ? document.querySelectorAll("${rowSelector}") : [];
218 | return Array.from(rowElements).map((element, rowIndex) => {
219 | return {
220 | id: String(rowIndex),
221 | index: rowIndex,
222 | dataValues: {
223 | rowElement: element
224 | },
225 | rowElements: [element]
226 | }
227 | });
228 | }`;
229 | }
230 |
231 | function _createAdapterId() {
232 | return document.title;
233 | }
--------------------------------------------------------------------------------
/src/end_user_scraper/constants.ts:
--------------------------------------------------------------------------------
1 | export const MIN_COLUMNS = 4;
2 | export const MOUSE_MOVE_COLOR = 'rgb(127, 140, 141)';
3 | export const MOUSE_CLICK_ROW_COLOR = MOUSE_MOVE_COLOR;
4 | export const ADAPTERS_BASE_KEY = 'localStorageAdapter:adapters';
5 | export const ACTIVE_COLOR = 'rgb(46, 204, 113)';
6 | export const INACTIVE_COLOR = 'rgb(127, 140, 141)';
--------------------------------------------------------------------------------
/src/end_user_scraper/domHelpers.ts:
--------------------------------------------------------------------------------
1 | function getAllClassCombinations(chars) {
2 | const result = [];
3 | const f = (prefix, chars) => {
4 | for (let i = 0; i < chars.length; i++) {
5 | result.push(`${prefix}.${chars[i]}`);
6 | f(`${prefix}.${chars[i]}`, chars.slice(i + 1));
7 | }
8 | };
9 | f('', chars);
10 | return result;
11 | }
12 |
13 | export function generateClassSelector(node, isRow, rowElement?) {
14 | if (node.classList && node.classList.length) {
15 | let selectors = [];
16 | const nodeTagName = node.tagName.toLowerCase();
17 | const allClassCombinations = getAllClassCombinations(Array.from(node.classList));
18 | if (isRow) {
19 | const siblings = Array
20 | .from(node.parentNode.children)
21 | .filter((element: HTMLElement) => !element.isSameNode(node));
22 | allClassCombinations.forEach((selector, i) => {
23 | selector = `${nodeTagName}${selector}`;
24 | selectors[i] = {
25 | selector,
26 | score: 0
27 | }
28 | const selectorClassNames= selector.substring(nodeTagName.length+1).split('.');
29 | siblings
30 | .filter((sibling: HTMLElement) => sibling.classList && sibling.classList.length)
31 | .map((sibling: HTMLElement) => Array.from(sibling.classList))
32 | .forEach(classList => {
33 | const allInClasslist = selectorClassNames.every(className => classList.includes(className));
34 | if (allInClasslist) {
35 | selectors[i].score += 1;
36 | }
37 | });
38 | });
39 | } else {
40 | allClassCombinations.forEach((selector, i) => {
41 | selector = `${nodeTagName}${selector}`;
42 | selectors[i] = {
43 | selector,
44 | score: 0
45 | }
46 | const selectorMatchesInRow = rowElement.querySelectorAll(selector);
47 | if (selectorMatchesInRow.length === 1 && selectorMatchesInRow[0].isSameNode(node)) {
48 | selectors[i].score += 1;
49 | }
50 | });
51 | }
52 | if (selectors.length && selectors.some(({ score }) => score > 0)) {
53 | selectors.sort((a, b) => b.score - a.score);
54 | const highestScore = selectors[0].score;
55 | selectors = selectors.filter(({ score }) => score === highestScore);
56 | selectors.sort((a, b) => a.selector.split('.').length - b.selector.split('.').length);
57 | const shortestLength = selectors[0].selector.split('.').length;
58 | selectors = selectors.filter(({ selector }) => selector.split('.').length === shortestLength);
59 | return selectors.map(s => s.selector);
60 | }
61 | }
62 | return [];
63 | }
64 |
65 | export function generateIndexSelector(node) {
66 | const tag = node.tagName.toLowerCase();
67 | const index = Array.prototype.indexOf.call(node.parentNode.children, node) + 1;
68 | return `${tag}:nth-child(${index})`;
69 | }
70 |
71 | export function getElementsBySelector(selector) {
72 | return document.querySelectorAll(selector);
73 | }
74 |
75 | export function areAllSiblings(node, selector) {
76 | return Array
77 | .from(getElementsBySelector(selector))
78 | .every(element => element.parentNode.isSameNode(node.parentNode));
79 | }
80 |
81 | export function generateClassSelectorFrom(node, from, isRow) {
82 | if (node.isSameNode(from)) {
83 | return null;
84 | }
85 | const selectors = [];
86 | let _node = node;
87 | if (isRow) {
88 | while (!_node.isSameNode(from)) {
89 | const selector = generateClassSelector(_node, isRow, from)[0] || _node.tagName.toLowerCase();
90 | selectors.unshift(selector);
91 | if (areAllSiblings(_node, selectors.join(' '))) {
92 | return selectors.join(' ');
93 | }
94 | _node = _node.parentNode;
95 | }
96 | return selectors.join(" ");
97 | }
98 | return generateClassSelector(_node, isRow, from)[0]
99 | }
100 |
101 | export function generateIndexSelectorFrom(node, from) {
102 | if (node.isSameNode(from)) {
103 | return null;
104 | }
105 | const selectors = [];
106 | let _node = node;
107 | while (!_node.isSameNode(from)) {
108 | selectors.unshift(generateIndexSelector(_node));
109 | _node = _node.parentNode;
110 | }
111 | return selectors.join('>');
112 | }
113 |
114 | export function inSelectorElements({ selector, node }) {
115 | const result = Array
116 | .from(document.querySelectorAll(selector))
117 | .filter(element => element.contains(node));
118 | return result.length === 1;
119 | }
120 |
--------------------------------------------------------------------------------
/src/end_user_scraper/generalizer.ts:
--------------------------------------------------------------------------------
1 | import {
2 | generateIndexSelectorFrom,
3 | generateIndexSelector,
4 | generateClassSelectorFrom,
5 | getElementsBySelector
6 | } from './domHelpers';
7 |
8 | export function findRowElement(nodes, lca) {
9 | const candidates = [];
10 | let selectors = nodes.map(node => generateIndexSelectorFrom(node, lca)).filter(selector => selector);
11 | let candidate = lca;
12 | while (candidate && candidate.tagName !== 'BODY') {
13 | const candidateEntry = {
14 | candidate,
15 | score: 0
16 | };
17 | let nextSibling = candidate.nextElementSibling;
18 | let previousSibling = candidate.previousElementSibling;
19 | while (nextSibling) {
20 | selectors.forEach(selector => {
21 | if (nextSibling.querySelector(selector)) {
22 | candidateEntry.score += 1;
23 | }
24 | });
25 | nextSibling = nextSibling.nextElementSibling;
26 | }
27 | while (previousSibling) {
28 | selectors.forEach(selector => {
29 | if (previousSibling.querySelector(selector)) {
30 | candidateEntry.score += 1;
31 | }
32 | });
33 | previousSibling = previousSibling.previousElementSibling;
34 | }
35 | candidates.push(candidateEntry);
36 | if (selectors.length) {
37 | selectors = selectors.map(selector => `${generateIndexSelector(candidate)}>${selector}`);
38 | } else {
39 | selectors = [generateIndexSelector(candidate)];
40 | }
41 | candidate = candidate.parentNode;
42 | }
43 | if (candidates.length) {
44 | candidates.sort((a, b) => b.score - a.score);
45 | const { candidate } = candidates[0];
46 | const rowElementSelector = generateClassSelectorFrom(candidate, document.querySelector('body'), true);
47 | return {
48 | rowElement: candidate,
49 | rowElementSelector,
50 | };
51 | }
52 | return null
53 | }
54 |
55 | export function generateColumnSelectors(rowElementSelector, nodes) {
56 | const selectors = [];
57 | const rowElements = getElementsBySelector(rowElementSelector);
58 | for (let i = 0; i < nodes.length; i++) {
59 | for (let j = 0; j < rowElements.length; j++) {
60 | if (rowElements[j].contains(nodes[i])) {
61 | let selector;
62 | const indexSelector = generateIndexSelectorFrom(nodes[i], rowElements[j]);
63 | const classSelector = generateClassSelectorFrom(nodes[i], rowElements[j], false);
64 | const classSelectorMatches = classSelector ? rowElements[j].querySelectorAll(classSelector) : [];
65 | if (classSelectorMatches.length === 1 && classSelectorMatches[0].isSameNode(nodes[i])) {
66 | selector = classSelector;
67 | } else {
68 | selector = indexSelector;
69 | }
70 | //const selector = generateIndexSelectorFrom(nodes[i], rowElements[j]);
71 | selectors.push(selector);
72 | break;
73 | }
74 | }
75 | }
76 | return selectors;
77 | }
--------------------------------------------------------------------------------
/src/end_user_scraper/index.ts:
--------------------------------------------------------------------------------
1 | import {
2 | run
3 | } from '../wildcard';
4 |
5 | import {
6 | addScrapingListeners,
7 | removeScrapingListeners
8 | } from './eventListeners';
9 |
10 | import {
11 | deleteAdapter,
12 | createAdapterKey,
13 | saveAdapter,
14 | createInitialAdapterConfig
15 | } from './adapterHelpers';
16 |
17 | import {
18 | initTutorial,
19 | removeTutorial,
20 | resetTutorial
21 | } from './tutorial';
22 |
23 | import {
24 | getAdapterKey,
25 | getCachedActiveAdapter,
26 | initState,
27 | resetScraperState,
28 | setCreatingAdapter
29 | } from './state';
30 |
31 | import { readFromChromeLocalStorage } from '../utils';
32 |
33 | export function startScrapingListener() {
34 | addScrapingListeners();
35 | initTutorial();
36 | setCreatingAdapter(true);
37 | run();
38 | }
39 |
40 | export function stopScrapingListener({ save }) {
41 | const adapterKey = getAdapterKey();
42 | removeScrapingListeners();
43 | if (!save) {
44 | deleteAdapter(adapterKey, () => {
45 | resetScraperState();
46 | removeTutorial();
47 | run();
48 | });
49 | } else {
50 | const activeAdapter = getCachedActiveAdapter();
51 | const adapterConfig = activeAdapter.getConfig();
52 | adapterConfig.attributes.pop();
53 | adapterConfig.metadata.columnSelectors.pop();
54 | adapterConfig.scrapePage = adapterConfig.scrapePage.toString();
55 | saveAdapter(adapterKey, adapterConfig, () => {
56 | resetScraperState();
57 | removeTutorial();
58 | run();
59 | });
60 | }
61 | }
62 |
63 | export function resetScrapingListener() {
64 | resetScraperState();
65 | resetTutorial();
66 | const activeAdapter = getCachedActiveAdapter();
67 | if (activeAdapter) {
68 | const config = createInitialAdapterConfig()
69 | activeAdapter.updateConfig(config);
70 | }
71 | }
72 |
73 | export function editScraper() {
74 | const cachedActiveAdapter = getCachedActiveAdapter();
75 | if (cachedActiveAdapter) {
76 | const adapterConfig = cachedActiveAdapter.getConfig();
77 | const adapterMetadata = adapterConfig.metadata;
78 | initState(adapterMetadata);
79 | addScrapingListeners();
80 | initTutorial();
81 | }
82 | // const adapterKey = createAdapterKey();
83 | // readFromChromeLocalStorage([adapterKey])
84 | // .then((results) => {
85 | // const adapterConfigString = results[adapterKey];
86 | // if (adapterConfigString) {
87 | // const adapterConfig = JSON.parse(adapterConfigString);
88 | // const adapterMetadata = adapterConfig.metadata;
89 | // initState(adapterMetadata);
90 | // addScrapingListeners();
91 | // initTutorial();
92 | // run({ creatingAdapter: true });
93 | // }
94 | // });
95 | }
--------------------------------------------------------------------------------
/src/end_user_scraper/state.ts:
--------------------------------------------------------------------------------
1 | import {
2 | copyMap,
3 | mapToArrayOfValues,
4 | randomRGB
5 | } from './utils';
6 |
7 | import {
8 | MIN_COLUMNS,
9 | ACTIVE_COLOR,
10 | INACTIVE_COLOR
11 | } from './constants';
12 |
13 | import {
14 | styleColumnElementsOnClick,
15 | styleRowElementsOnClick
16 | } from './eventListeners';
17 | import { updateAdapter } from './adapterHelpers';
18 |
19 | const _eventMaps = {
20 | mouseMoveRowElement: new Map(),
21 | mouseMoveColumnElement: new Map(),
22 | mouseClickRowElement: new Map(),
23 | mouseClickColumnElement: new Map(),
24 | defaults: new Map()
25 | };
26 | let _tempColumnMap;
27 | let _columnColorsList = [];
28 | let _adapterKey;
29 | let _rowElementSelector;
30 | let _rowElement;
31 | let _column = 0;
32 | let _exploring = true;
33 | let _currentColumnSelector;
34 | let _multipleExamples = false;
35 | let _columnMap = new Map();
36 | let _editing = false;
37 | let _candidateRowElementSelectors = [];
38 | let _candidateColumnElementSelectors = [];
39 | let _activeAdapter;
40 | let _rowElementSelectorCandidates;
41 | let _columnElementSelectorCandidates = [];
42 | let _creatingAdapter = false;
43 | _columnMap.set(_column, []);
44 |
45 | export function initState({ rowSelector, columnSelectors, id }) {
46 | _editing = true;
47 | _exploring = false;
48 | _rowElementSelector = rowSelector;
49 | _rowElement = document.querySelector(rowSelector);
50 | _adapterKey = id;
51 | columnSelectors.forEach((selectors, index) => {
52 | _columnMap.set(index, selectors);
53 | });
54 | _column = columnSelectors.length;
55 | _columnMap.set(_column, []);
56 | _tempColumnMap = copyMap(_columnMap);
57 | styleColumnElementsOnClick(rowSelector);
58 | styleRowElementsOnClick();
59 | updateAdapter(id, mapToArrayOfValues(_columnMap), rowSelector);
60 | }
61 |
62 | export function setStyleAndAddToMap({ map, node, styleProperty, styleValue }) {
63 | if (!_eventMaps.defaults.has(node)) {
64 | _eventMaps.defaults.set(node, node.style);
65 | }
66 | map.set(node, {
67 | property: styleProperty,
68 | value: node.style[styleProperty],
69 | set: styleValue
70 | });
71 | node.style[styleProperty] = styleValue;
72 | }
73 |
74 | export function clearElementMap(map, clear?) {
75 | map.forEach(({ property, value, set }, element) => {
76 | if (element.style) {
77 | if (clear) {
78 | if (_eventMaps.defaults.get(element)) {
79 | element.style = _eventMaps.defaults.get(element);
80 | } else {
81 | delete element.style;
82 | }
83 | } else if (element.style[property] === set) {
84 | element.style[property] = value;
85 | }
86 | }
87 | });
88 | map.clear();
89 | }
90 |
91 | export function clearElementMaps() {
92 | Object.keys(_eventMaps)
93 | .filter(mapKey => mapKey !== 'defaults')
94 | .forEach(mapKey => {
95 | clearElementMap(_eventMaps[mapKey], true);
96 | });
97 | _eventMaps.defaults.clear();
98 | }
99 |
100 | export function populateColumnColors() {
101 | for (let i = 0; i < MIN_COLUMNS; i++) {
102 | _columnColorsList.push(randomRGB());
103 | }
104 | }
105 |
106 | export function getAdapterKey() {
107 | return _adapterKey;
108 | }
109 |
110 | export function setAdapterKey(adapterKey) {
111 | _adapterKey = adapterKey;
112 | }
113 |
114 | export function getRowElementSelector() {
115 | return _rowElementSelector;
116 | }
117 |
118 | export function setRowElementSelector(rowElementSelector) {
119 | _rowElementSelector = rowElementSelector;
120 | }
121 |
122 | export function getRowElement() {
123 | return _rowElement;
124 | }
125 |
126 | export function setRowElement(rowElement) {
127 | _rowElement = rowElement;
128 | }
129 |
130 | export function getColumn() {
131 | return _column;
132 | }
133 |
134 | export function setColumn(column) {
135 | _column = column;
136 | }
137 |
138 | export function getColumnMap() {
139 | return _columnMap;
140 | }
141 |
142 | export function setColumnMap(map) {
143 | _columnMap = new Map([...map.entries()].sort());
144 | }
145 |
146 | export function setTempColumnMap(value) {
147 | _tempColumnMap = value;
148 | }
149 |
150 | export function getTempColumnMap() {
151 | return _tempColumnMap;
152 | }
153 |
154 | export function getColumnColor(i) {
155 | if (!_columnColorsList[i]) {
156 | _columnColorsList[i] = randomRGB();
157 | }
158 | return _columnColorsList[i];
159 |
160 | }
161 |
162 | export function getEventMaps() {
163 | return _eventMaps;
164 | }
165 |
166 | export function resetScraperState() {
167 | _editing = false;
168 | _adapterKey = null;
169 | _rowElement = null;
170 | _rowElementSelector = null;
171 | _column = 0;
172 | _columnMap.clear();
173 | _columnMap.set(_column, []);
174 | clearElementMaps();
175 | _columnColorsList = [];
176 | _exploring = true;
177 | _currentColumnSelector = null;
178 | _tempColumnMap = null;
179 | _multipleExamples = false;
180 | _candidateRowElementSelectors = [];
181 | _candidateColumnElementSelectors = [];
182 | _rowElementSelectorCandidates = [];
183 | _columnElementSelectorCandidates = [];
184 | _creatingAdapter = false;
185 | }
186 |
187 | export function getMouseClickRowStyleData() {
188 | return {
189 | styleProperty: 'border',
190 | styleValue: `2px solid ${ACTIVE_COLOR}`
191 | }
192 | }
193 |
194 | export function getMouseClickColumnStyleProperty() {
195 | return 'backgroundColor';
196 | }
197 |
198 | export function getMouseClickColumnStyleValue() {
199 | return INACTIVE_COLOR;
200 | }
201 |
202 | export function getMouseMoveRowStyleData() {
203 | return {
204 | styleProperty: 'border',
205 | styleValue: `2px solid ${ACTIVE_COLOR}`
206 | }
207 | }
208 |
209 | export function getMouseMoveColumnStyleData() {
210 | return {
211 | styleProperty: 'backgroundColor',
212 | styleValue: ACTIVE_COLOR
213 | }
214 | }
215 |
216 | export function getExploring() {
217 | return _exploring;
218 | }
219 |
220 | export function setExploring(exploring) {
221 | _exploring = exploring;
222 | }
223 |
224 | export function getCurrentColumnSelector() {
225 | return _currentColumnSelector;
226 | }
227 |
228 | export function setCurrentColumnSelector(value) {
229 | _currentColumnSelector = value;
230 | }
231 |
232 | export function getMultipleExamples() {
233 | return _multipleExamples;
234 | }
235 |
236 | export function setMultipleExamples(value) {
237 | _multipleExamples = value;
238 | }
239 |
240 | export function getEditing() {
241 | return _editing;
242 | }
243 |
244 | export function setEditing(value) {
245 | _editing = value;
246 | }
247 |
248 | export function getCandidateRowElementSelectors() {
249 | return _candidateRowElementSelectors;
250 | }
251 |
252 | export function setCandidateRowElementSelectors(value) {
253 | _candidateRowElementSelectors = value;
254 | }
255 |
256 | export function getCandidateColumnElementSelectors() {
257 | return _candidateColumnElementSelectors;
258 | }
259 |
260 | export function setCandidateColumnElementSelectors(value) {
261 | _candidateColumnElementSelectors = value;
262 | }
263 |
264 | export function getCachedActiveAdapter() {
265 | return _activeAdapter;
266 | }
267 |
268 | export function setCachedActiveAdapter(value) {
269 | _activeAdapter = value;
270 | }
271 |
272 | export function getRowElementSelectorCandidates() {
273 | return _rowElementSelectorCandidates;
274 | }
275 |
276 | export function setRowElementSelectorCandidates(value) {
277 | _rowElementSelectorCandidates = value;
278 | }
279 |
280 | export function getColumnElementSelectorCandidates() {
281 | return _columnElementSelectorCandidates;
282 | }
283 |
284 | export function setColumnElementSelectorCandidates(value) {
285 | _columnElementSelectorCandidates = value;
286 | }
287 |
288 | export function getCreatingAdapter() {
289 | return _creatingAdapter;
290 | }
291 |
292 | export function setCreatingAdapter(value) {
293 | _creatingAdapter = value;
294 | }
--------------------------------------------------------------------------------
/src/end_user_scraper/tutorial.ts:
--------------------------------------------------------------------------------
1 | import {
2 | htmlToElement
3 | } from '../utils';
4 |
5 | import {
6 | getColumn,
7 | setColumn,
8 | getColumnMap,
9 | setMultipleExamples,
10 | getEventMaps,
11 | clearElementMap,
12 | getEditing
13 | } from './state';
14 |
15 | import {
16 | ACTIVE_COLOR,
17 | INACTIVE_COLOR
18 | } from './constants';
19 |
20 | import { indexToAlpha } from './utils';
21 |
22 | import {
23 | styleRowElementsOnHover
24 | } from './eventListeners';
25 |
26 | const TUTORIAL_BACKGROUND_COLOR = 'rgb(255, 255, 255)';
27 | const TUTORIAL_TEXT_COLOR = 'rgb(0, 0, 0)';
28 |
29 |
30 |
31 | let _tutorialElement;
32 | let _scraperControlsElement;
33 |
34 | function getTutorialHTMLString() {
35 | const editting = getEditing();
36 | const cancelButtonText = editting ? 'Delete' : 'Cancel';
37 | const saveButtonText = editting ? 'Save' : 'Done';
38 | return `
39 |
40 |
41 |
42 | Alt + click (option instead of alt on Mac) on a field you wish to scrape
43 |
44 |
45 |
46 |
47 |
48 |
49 | Restart
50 | ${cancelButtonText}
51 | ${saveButtonText}
52 |
53 |
54 |
55 | `;
56 | }
57 |
58 | function createColumnBoxString({ column, color, index }) {
59 | return `
60 |
61 | ${column}
62 |
63 | `
64 | }
65 |
66 | function createColumnBoxElement({ column, color, index }) {
67 | const html = createColumnBoxString({ column, color, index });
68 | return htmlToElement(html);
69 | }
70 |
71 | function columnBoxListener(event) {
72 | const target = event.target;
73 | if (target.classList.contains('column-box')) {
74 | const columnMap = getColumnMap();
75 | const column = parseInt(target.id);
76 | setColumn(column);
77 | if (column === columnMap.size - 1) {
78 | setMultipleExamples(false);
79 | } else {
80 | setMultipleExamples(true);
81 | }
82 | renderColumnBoxes(columnMap);
83 | const eventMaps = getEventMaps();
84 | clearElementMap(eventMaps.mouseMoveRowElement);
85 | styleRowElementsOnHover();
86 | }
87 | }
88 |
89 | function clearColumnBoxes() {
90 | const columnContainer = document.querySelector('#columnContainer');
91 | columnContainer.innerHTML = '';
92 | removeColumnBoxListener();
93 | }
94 |
95 | function populateColumnBoxes(columnMap, column?) {
96 | const columnContainer = document.querySelector('#columnContainer');
97 | const columns = columnMap.size;
98 | const activeColumn = Number.isInteger(column) ? column : getColumn();
99 | if (columns) {
100 | for (let i = 0; i < columns; i++) {
101 | const columnBoxElement = createColumnBoxElement({
102 | column: indexToAlpha(i),
103 | color: i === activeColumn ? ACTIVE_COLOR : INACTIVE_COLOR,
104 | index: i
105 | });
106 | columnContainer.appendChild(columnBoxElement);
107 | }
108 | addColumnBoxListener();
109 | }
110 | }
111 |
112 | function addColumnBoxListener() {
113 | if (_tutorialElement) {
114 | document.querySelector('#columnContainer').addEventListener('click', columnBoxListener);
115 | }
116 | }
117 |
118 | function removeColumnBoxListener(){
119 | if (_tutorialElement) {
120 | document.querySelector('#columnContainer').addEventListener('click', columnBoxListener);
121 | }
122 | }
123 |
124 | export function renderColumnBoxes(columnMap, column?) {
125 | clearColumnBoxes();
126 | populateColumnBoxes(columnMap, column);
127 | }
128 |
129 | function createColumnLabel(columnIndex) {
130 | return String.fromCharCode(97 + columnIndex).toUpperCase()
131 | }
132 |
133 | function columnControlListener(event){
134 | const columnMap = getColumnMap();
135 | const column = getColumn();
136 | if (event.target.id === 'prevButton') {
137 | const proposed = column - 1;
138 | if (columnMap.has(proposed)) {
139 | setColumn(proposed);
140 | _scraperControlsElement.querySelector('#columnNumber').textContent = createColumnLabel(proposed);
141 | } else {
142 | alert(`You are trying to switch to an invalid column.`);
143 | }
144 | } else {
145 | const proposed = column + 1;
146 | if (!columnMap.get(column) || (columnMap.get(column) && !columnMap.get(column).length)) {
147 | alert(`Please select fields for column ${createColumnLabel(column)} before moving to column ${createColumnLabel(proposed)}.`);
148 | return;
149 | }
150 | if (!columnMap.has(proposed)) {
151 | columnMap.set(proposed, []);
152 | }
153 | setColumn(proposed);
154 | _scraperControlsElement.querySelector('#columnNumber').textContent = createColumnLabel(proposed);
155 | }
156 | }
157 |
158 | function scraperControlsListener(event) {
159 | switch (event.target.id) {
160 | case 'startOverButton':
161 | chrome.runtime.sendMessage({ command: 'resetAdapter'})
162 | break;
163 | case 'cancelButton':
164 | chrome.runtime.sendMessage({ command: 'deleteAdapter'})
165 | break;
166 | case 'saveButton':
167 | chrome.runtime.sendMessage({ command: 'saveAdapter'})
168 | break;
169 | default:
170 | break;
171 | }
172 | }
173 |
174 | function addColumnControlListeners() {
175 | if (_tutorialElement) {
176 | _tutorialElement.querySelector('#startOverButton').addEventListener('click', scraperControlsListener);
177 | _tutorialElement.querySelector('#cancelButton').addEventListener('click', scraperControlsListener);
178 | _tutorialElement.querySelector('#saveButton').addEventListener('click', scraperControlsListener);
179 | }
180 | }
181 |
182 | function removeColumnControlListeners() {
183 | if (_tutorialElement) {
184 | _tutorialElement.querySelector('#startOverButton').removeEventListener('click', scraperControlsListener);
185 | _tutorialElement.querySelector('#cancelButton').removeEventListener('click', scraperControlsListener);
186 | _tutorialElement.querySelector('#saveButton').removeEventListener('click', scraperControlsListener);
187 | }
188 | }
189 |
190 | export function getTutorialElement() {
191 | return _tutorialElement;
192 | }
193 |
194 | export function initTutorial() {
195 | _tutorialElement = htmlToElement(getTutorialHTMLString());
196 | document.body.prepend(_tutorialElement);
197 | addColumnControlListeners();
198 | renderColumnBoxes(getColumnMap());
199 | }
200 |
201 | export function removeTutorial() {
202 | if (_tutorialElement) {
203 | clearColumnBoxes();
204 | removeColumnControlListeners();
205 | _tutorialElement.remove();
206 | _tutorialElement = null;
207 | }
208 | }
209 |
210 | export function resetTutorial() {
211 | removeTutorial();
212 | initTutorial();
213 | }
--------------------------------------------------------------------------------
/src/end_user_scraper/utils.ts:
--------------------------------------------------------------------------------
1 | import {
2 | ADAPTERS_BASE_KEY
3 | } from './constants';
4 | import { getColumnMap } from './state';
5 |
6 | export function randomRGB() {
7 | const o = Math.round, r = Math.random, s = 255;
8 | return 'rgba(' + o(r()*s) + ',' + o(r()*s) + ',' + o(r()*s) + ')';
9 | }
10 |
11 | export function getAllCombinations(chars) {
12 | const result = [];
13 | const f = (prefix, chars) => {
14 | for (let i = 0; i < chars.length; i++) {
15 | result.push(`${prefix}.${chars[i]}`);
16 | f(`${prefix}.${chars[i]}`, chars.slice(i + 1));
17 | }
18 | };
19 | f('', chars);
20 | return result;
21 | }
22 |
23 | export function mapToArrayOfValues(map) {
24 | const result: Array> = [];
25 | map.forEach((value) => {
26 | result.push(value);
27 | })
28 | return result;
29 | }
30 |
31 | export function copyMap(map) {
32 | const result = new Map();
33 | for (let [column, selectors] of map) {
34 | if (Array.isArray(selectors)) {
35 | result.set(column, [...selectors]);
36 | } else {
37 | result.set(column, selectors);
38 | }
39 | }
40 | return result;
41 | }
42 |
43 | export function applyToColumnMap(map) {
44 | const columnMap = getColumnMap();
45 | for (let [column, selectors] of map) {
46 | if (Array.isArray(selectors)) {
47 | columnMap.set(column, [...selectors]);
48 | } else {
49 | columnMap.set(column, selectors);
50 | }
51 | }
52 | }
53 |
54 | export function generateAdapterKey(id) {
55 | return `${ADAPTERS_BASE_KEY}:${id}`
56 | }
57 |
58 | export function newSelector(selector, columnMap) {
59 | let seen = false;
60 | for (let [column, selectors] of columnMap) {
61 | if (Array.isArray(selectors)) {
62 | if (selectors.includes(selector)) {
63 | seen = true;
64 | break;
65 | }
66 | }
67 | }
68 | return !seen;
69 | }
70 |
71 | export function getColumnForSelector(columnMap, selector) {
72 | for (let [column, selectors] of columnMap) {
73 | if (Array.isArray(selectors)) {
74 | if (selectors.includes(selector)) {
75 | return column;
76 | }
77 | }
78 | }
79 | return null;
80 | }
81 |
82 | export function indexToAlpha(i) {
83 | return String.fromCharCode(97 + i).toUpperCase();
84 | }
85 |
86 | export function getSelectorFromQueryFormula({ formula }) {
87 | const regex = /\=QuerySelector\(rowElement,\s*"(.+)"\)/;
88 | const matches = formula.match(regex);
89 | if (Array.isArray(matches)){
90 | return matches[1];
91 | }
92 | return null;
93 | }
94 |
95 | export function isFormula(value) {
96 | return value.startsWith("=");
97 | }
--------------------------------------------------------------------------------
/src/localStorageAdapter.ts:
--------------------------------------------------------------------------------
1 | 'use strict';
2 | declare const browser;
3 |
4 | import { TableAdapter, Record, Attribute, TableCallback, RecordEdit } from './core/types'
5 | import { createDomScrapingAdapter } from './site_adapters/domScrapingBase';
6 | import { readFromChromeLocalStorage, compileJavascript } from './utils'
7 |
8 | const emptyTable = {
9 | tableId: "user",
10 | attributes: [],
11 | records: []
12 | }
13 |
14 | let table = emptyTable
15 |
16 | // todo: could we use tableId instead of a separate "namespace" here?
17 | // especially because we might not always want to launch the same user table
18 | // for the same site adapter. we should add some indirection somewhere,
19 | // so a site adapter loads a specific user table by default, but
20 | // you could install someone else's user table, or even have multiple
21 | // possible user tables stored within a single adapter, all joined together
22 | let namespace;
23 |
24 | let subscribers:Array = []
25 |
26 | const storageKey = () => `localStorageAdapter:${namespace}`
27 |
28 | const loadTable = () => {
29 | chrome.storage.local.set({ [storageKey()]: table });
30 | for (const callback of subscribers) { callback(table); }
31 | return table;
32 | }
33 |
34 | const editRecords = (edits:Array) => {
35 | for (const { recordId, attribute, value } of edits) {
36 | let newRecords : Array;
37 |
38 | // todo: this does two passes, inefficient
39 | const existingRecord = table.records.find(r => r.id === recordId)
40 | if (existingRecord) {
41 | newRecords = table.records.map(r => {
42 | if (r.id === recordId) {
43 | return {
44 | id: r.id,
45 | values: { ...r.values, [attribute]: value }
46 | }
47 | }
48 | else { return r; }
49 | })
50 | } else {
51 | newRecords = [...table.records,
52 | { id: recordId, values: { [attribute]: value } }
53 | ]
54 | }
55 |
56 | table = { ...table, records: newRecords }
57 | }
58 | return Promise.resolve(loadTable());
59 | }
60 |
61 | export const userStore:TableAdapter = {
62 | tableId: "user",
63 | name: "User Local Storage",
64 | initialize: (ns) => {
65 | namespace = ns;
66 | chrome.storage.local.get([storageKey()], (result) => {
67 | const tableFromStorage = result[storageKey()];
68 | if (tableFromStorage) { table = tableFromStorage; loadTable(); }
69 | })
70 | },
71 | enabled: () => true , // user store is always enabled
72 | clear: () => {
73 | table = emptyTable
74 | loadTable()
75 | },
76 | loadTable: loadTable,
77 | subscribe(callback:TableCallback) {
78 | subscribers = [...subscribers, callback];
79 | },
80 | editRecords: editRecords,
81 | addAttribute() {
82 | const newAttribute : Attribute = {
83 | name: "user" + (table.attributes.length + 1),
84 | type: "text",
85 | editable: true,
86 | hideInPage: false
87 | }
88 |
89 | table = { ...table, attributes: [...table.attributes, newAttribute] }
90 |
91 | loadTable();
92 |
93 | return Promise.resolve(table);
94 | },
95 | toggleVisibility(colName) {
96 |
97 | var curr = table.attributes.find((attribute) => (attribute.name === colName));
98 | curr.hideInPage = !curr.hideInPage;
99 |
100 | loadTable();
101 |
102 | return;
103 | },
104 | setFormula(attrName, formula) {
105 | table = { ...table,
106 | attributes: table.attributes.map(attr => attr.name === attrName ? { ...attr, formula } : attr )}
107 |
108 | loadTable();
109 | },
110 |
111 | // These changes to the table are no-ops
112 | // todo: should these move off the generic table adapter interface?
113 | // should they only apply to dom adapters?
114 | handleRecordSelected() {}, //no-op
115 | applySort() {},
116 | handleOtherTableUpdated() {},
117 | }
118 |
119 | export const adapterStore = {
120 | getLocalAdapters: async () => {
121 | const localAdaptersKey = 'localStorageAdapter:adapters';
122 | const result = [];
123 | try {
124 | const localAdapters = (await readFromChromeLocalStorage([localAdaptersKey]) as Object)[localAdaptersKey] || [];
125 | for (let i = 0; i < localAdapters.length; i++) {
126 | const adapter = localAdapters[i];
127 | const localAdapterKey = `${localAdaptersKey}:${adapter}`;
128 | const adapterConfigString = (await readFromChromeLocalStorage([localAdapterKey]) as Object)[localAdapterKey];
129 | // sometimes we can end up with malformed adapters; just ignore and keep going
130 | if(!adapterConfigString) continue;
131 | const adapterConfig = JSON.parse(adapterConfigString);
132 | compileAdapterJavascript(adapterConfig);
133 | const localAdapter = createDomScrapingAdapter(adapterConfig);
134 | result.push(localAdapter);
135 | }
136 | } catch(error){
137 | console.error('error while retrieving local adapters:', error);
138 | }
139 | return result;
140 | }
141 | }
142 |
143 | export function compileAdapterJavascript(adapterConfig) {
144 | const keysToEvaluate = ['scrapePage', 'onRowSelected', 'onRowUnselected', 'addScrapeTriggers'];
145 | Object.keys(adapterConfig)
146 | .filter(key => keysToEvaluate.includes(key))
147 | .forEach(key => {
148 | adapterConfig[key] = compileJavascript(adapterConfig[key]);
149 | });
150 | }
151 |
--------------------------------------------------------------------------------
/src/marketplace.js:
--------------------------------------------------------------------------------
1 | const localAdaptersKey = 'localStorageAdapter:adapters';
2 | let LOCAL_ADAPTERS;
3 |
4 | window.onload = function(e){
5 | const queryString = window.location.search;
6 | const urlParams = new URLSearchParams(queryString);
7 | const key = urlParams.get('key');
8 |
9 | // don't do anything if key is not available
10 | if (key == null || key == "")
11 | return;
12 |
13 | // read adapter code from storage
14 | chrome.storage.local.get(key, (results) => {
15 | const code = results[key];
16 |
17 | // populate fields
18 | document.getElementById("code").textContent = code;
19 |
20 | // get name and url from adapter code
21 |
22 | // const scraper = new Function(`return ${code}`)();
23 | // document.getElementById("name").value = scraper.name;
24 | // document.getElementById("url").value = scraper.contains;
25 |
26 | const json = JSON.parse(code);
27 | document.getElementById("name").value = json.name;
28 | document.getElementById("url").value = json.urls[0];
29 |
30 | document.getElementById("upload").removeAttribute('disabled');
31 | });
32 |
33 | }
34 |
35 | // listen for custom event to ensure adapter info has been parsed
36 | document.addEventListener('adapterReady', function (e) {
37 | const adapterName = document.getElementById("adapterName").textContent;
38 | const adapterCode = document.getElementById("adapterCode").textContent;
39 | const url = document.getElementById("url").textContent;
40 |
41 | const installBtn = document.getElementById("install");
42 | const uninstallBtn = document.getElementById("uninstall");
43 | const status = document.getElementById('status');
44 |
45 | // check if adapter installed
46 | // load LOCAL_ADAPTERS
47 | chrome.storage.local.get([localAdaptersKey], (results) => {
48 | LOCAL_ADAPTERS = results[localAdaptersKey];
49 | // initialize LOCAL_ADAPTERS if needed
50 | if (LOCAL_ADAPTERS === undefined) {
51 | LOCAL_ADAPTERS = [];
52 | installBtn.removeAttribute('disabled');
53 | } else if (Array.isArray(LOCAL_ADAPTERS) && LOCAL_ADAPTERS.indexOf(adapterName) > -1) {
54 | // hide Install button
55 | installBtn.style.display = "none";
56 | uninstallBtn.style.display = "inline";
57 | status.textContent = "Adapter already installed.";
58 | } else {
59 | installBtn.removeAttribute('disabled');
60 | }
61 | });
62 |
63 | // add onclick to install and uninstall buttons
64 | installBtn.onclick = function () {
65 | // if adapterName not exist, update LOCAL_ADAPTERS and create one
66 | if (Array.isArray(LOCAL_ADAPTERS) && LOCAL_ADAPTERS.indexOf(adapterName) === -1) {
67 | LOCAL_ADAPTERS.push(adapterName);
68 | chrome.storage.local.set({ [localAdaptersKey]: LOCAL_ADAPTERS }, function () {
69 | const key = `${localAdaptersKey}:${adapterName}`;
70 | chrome.storage.local.set({ [key]: adapterCode }, () => {
71 | // Update status to let user know adapter was installed.
72 | status.textContent = "Adapter installed.";
73 | installBtn.style.display = "none";
74 | uninstallBtn.style.display = "inline";
75 |
76 | // open a window with the target url with delay
77 | setTimeout(() => {
78 | window.open(url);
79 | }, 1000);
80 | });
81 | });
82 | }
83 | }
84 |
85 | uninstallBtn.onclick = function () {
86 | // if adapterName exists, remove it from storage
87 | if (Array.isArray(LOCAL_ADAPTERS) && LOCAL_ADAPTERS.indexOf(adapterName) !== -1) {
88 | LOCAL_ADAPTERS.splice(LOCAL_ADAPTERS.indexOf(adapterName), 1);
89 | chrome.storage.local.set({ [localAdaptersKey]: LOCAL_ADAPTERS });
90 |
91 | const key = `${localAdaptersKey}:${adapterName}`;
92 | chrome.storage.local.remove(key, () => {
93 | status.textContent = "Adapter removed.";
94 | // hide Uninstall button, show Install button
95 | installBtn.removeAttribute('disabled');
96 | installBtn.style.display = "inline";
97 | uninstallBtn.style.display = "none";
98 | });
99 | }
100 | }
101 | });
102 |
--------------------------------------------------------------------------------
/src/site_adapters/airbnb.ts:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | import { urlContains, extractNumber } from "../utils"
4 | import { createDomScrapingAdapter } from "./domScrapingBase"
5 |
6 | const rowContainerClass = "_fhph4u"
7 | const rowClass = "_8ssblpx"
8 | const titleClass = "_1jbo9b6h"
9 | const titleClassBackup = "_1c2n35az"
10 | const priceClass = "_1p7iugi"
11 | const ratingClass = "_3zgr580"
12 | const ratingClassBackup = "_10fy1f8"
13 | const listingLinkClass = "_i24ijs"
14 |
15 | const AirbnbAdapter = createDomScrapingAdapter({
16 | name: "Airbnb",
17 | enabled: () => urlContains("airbnb.com/s"),
18 | attributes: [
19 | { name: "id", type: "text", hidden: true },
20 | { name: "name", type: "text" },
21 | { name: "price", type: "numeric" },
22 | { name: "rating", type: "numeric" },
23 | {name: "latitude", type: "numeric"},
24 | {name: "longitude", type: "numeric"}
25 | ],
26 | scrapePage: () => {
27 | return Array.from(document.getElementsByClassName(rowClass)).map(el => {
28 | let path = el.querySelector("." + listingLinkClass).getAttribute('href')
29 | let id = path.match(/\/rooms\/([0-9]*)\?/)[1]
30 | let rating = el.querySelector(`.${ratingClass}`) !== null
31 | ? el.querySelector(`.${ratingClass}`)
32 | : el.querySelector(`.${ratingClassBackup}`);
33 |
34 | return {
35 | id: id,
36 | rowElements: [el],
37 | dataValues: {
38 | name: el.querySelector(`.${titleClass}`) !== null
39 | ? el.querySelector(`.${titleClass}`)
40 | : el.querySelector(`.${titleClassBackup}`),
41 | price: el.querySelector(`.${priceClass}`).textContent.match(/\$([\d]*)/)[1],
42 | rating: extractNumber(rating)
43 | },
44 | annotationContainer: el.querySelector("div._kqh46o") as HTMLElement,
45 | annotationTemplate: ` · $annotation `
46 | }
47 | })
48 | },
49 | scrapeAjax: (request) => {
50 | if(request.url.includes("https://www.airbnb.com/api/v3?")){
51 | try{
52 | let listings = request.data.data.dora.exploreV3.sections["1"].items;
53 | return Object.keys(listings).map(key => {
54 | let listing = listings[key].listing;
55 |
56 | return {
57 | id: listing.id,
58 | dataValues: {
59 | latitude: listing.lat,
60 | longitude: listing.lng
61 | }
62 | }
63 | });
64 | }
65 | catch{
66 |
67 | }
68 | }
69 | return undefined;
70 | },
71 | });
72 |
73 | export default AirbnbAdapter;
74 |
75 |
--------------------------------------------------------------------------------
/src/site_adapters/amazon.ts:
--------------------------------------------------------------------------------
1 | import { urlContains } from '../utils'
2 | import { createDomScrapingAdapter } from "./domScrapingBase"
3 |
4 | const rowContainerID = "olpOfferList";
5 | const rowClass = "a-row a-spacing-mini olpOffer";
6 | const priceClass = "a-column a-span2 olpPriceColumn";
7 | const shippingPriceClass = "olpShippingPrice";
8 | const estTaxClass = "olpEstimatedTaxText";
9 | const conditionClass = "a-size-medium olpCondition a-text-bold";
10 | const arrivalClass = "a-expander-content a-expander-partial-collapse-content";
11 | const sellerClass = "a-column a-span2 olpSellerColumn";
12 | const sellerName = "a-spacing-none olpSellerName";
13 | const ratingClass = "a-icon-alt";
14 |
15 | export const AmazonAdapter = createDomScrapingAdapter({
16 | name: "Amazon",
17 | enabled: () => urlContains("amazon.com/gp/offer-listing/"),
18 | attributes: [
19 | { name: "id", type: "text", hidden: true },
20 | { name: "total_price", editable: true, type: "numeric" },
21 | { name: "condition", editable: true, type: "text" },
22 | { name: "delivery_detail", editable: true, type: "text" },
23 | { name: "rating", editable: true, type: "numeric" }
24 | ],
25 | scrapePage: () => {
26 |
27 | var group = document.getElementById(rowContainerID).getElementsByClassName(rowClass);
28 |
29 | return Array.from(group).map(el => {
30 | var price = 0;
31 |
32 | var price_el = el.getElementsByClassName(priceClass)[0];
33 | var price_text = price_el.innerText;
34 |
35 | var start_idx = price_text.indexOf("$");
36 | var end_idx = price_text.indexOf(" ");
37 |
38 | //find every "$" sign and add the number behind it to the total_price
39 | while(start_idx != -1){
40 | price += parseFloat(price_text.substring(start_idx + 1, end_idx));
41 | start_idx = price_text.indexOf("$", end_idx);
42 | end_idx = price_text.indexOf(" ", start_idx);
43 | }
44 |
45 |
46 | var delivery_el = el.getElementsByClassName(arrivalClass)[0];
47 | var delivery_text = "";
48 | if (delivery_el == undefined){
49 | delivery_text = "Unavailable";
50 | }
51 | else {
52 | delivery_text = delivery_el.innerText;
53 | }
54 |
55 |
56 | var rating_el = el.getElementsByClassName(sellerClass)[0].getElementsByClassName(ratingClass)[0];
57 | var rating_text = "";
58 | if (rating_el == undefined){
59 | rating_text = "Unavailable";
60 | }
61 | else{
62 | rating_text = rating_el.innerText;
63 | }
64 |
65 | var seller_name = el.getElementsByClassName(sellerName)[0];
66 | var seller_href = "";
67 | if (seller_name.querySelector("a") === null){
68 | seller_href = "Null";
69 | }
70 | else{
71 | seller_href = seller_name.querySelector("a").href;
72 | }
73 |
74 | var condition = el.getElementsByClassName(conditionClass)[0];
75 | console.log(condition);
76 | var cond_text = "";
77 | if (condition == undefined){
78 | cond_text = "Unavailable";
79 | }
80 | else{
81 | cond_text = condition.innerText;
82 | }
83 |
84 |
85 | return {
86 | id: seller_href,
87 | rowElements: [el],
88 | dataValues: {
89 | total_price: price.toFixed(2),
90 | condition: cond_text,
91 | delivery_detail: delivery_text,
92 | rating: rating_text
93 | }
94 | }
95 | })
96 | }
97 | });
98 |
99 | export default AmazonAdapter;
100 |
--------------------------------------------------------------------------------
/src/site_adapters/blogger.ts:
--------------------------------------------------------------------------------
1 | import { RichTextEditor } from '../ui/cell_editors/richTextEditor.js'
2 | import { createDomScrapingAdapter } from "./domScrapingBase"
3 | import {urlContains} from "../utils";
4 |
5 | const BloggerAdapter = createDomScrapingAdapter({
6 | name: "Blogger",
7 | enabled: () => urlContains("blogger.com"),
8 | attributes: [
9 | { name: "id", type: "text", hidden: true },
10 | { name: "document", editable: true, renderer: 'html', type: "text", editor: RichTextEditor },
11 | { name: "source", editable: true, type: "text", editor: RichTextEditor },
12 | ],
13 | scrapePage: () => {
14 | let container : HTMLElement = document.getElementById("blogger-app");
15 | let iframeLoaded = document.querySelectorAll('iframe').length == 3;
16 | let doc = iframeLoaded ? document.querySelectorAll('iframe')[2].contentDocument.body : container.querySelector("#postingComposeBox");
17 | return [
18 | {
19 | id: "1",
20 | rowElements: [container],
21 | dataValues: {
22 | document: doc,
23 | source: container.querySelector("#postingHtmlBox"),
24 | }
25 | }
26 | ]
27 | },
28 | // Reload data anytime there's a click or keypress on the page
29 | addScrapeTriggers: (reload) => {
30 | document.addEventListener("click", (e) => { reload() });
31 | document.addEventListener("keydown", (e) => { reload() });
32 | },
33 | iframe: true
34 | });
35 |
36 | export default BloggerAdapter
37 |
--------------------------------------------------------------------------------
/src/site_adapters/expedia.ts:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | // todo: doesn't make sense to import cell editors here.... should be in UI?
4 | import { FullCalendarEditor } from '../ui/cell_editors/fullCalendarEditor.js'
5 | import { RichTextEditor } from '../ui/cell_editors/richTextEditor.js'
6 | import { urlContains } from '../utils'
7 | import { createDomScrapingAdapter } from "./domScrapingBase"
8 |
9 | const ExpediaAdapter = createDomScrapingAdapter({
10 | name: "Expedia",
11 | enabled: () => urlContains("expedia.com"),
12 | attributes: [
13 | { name: "id", type: "text", hidden: true },
14 | { name: "origin", editable: true, type: "text" },
15 | { name: "destination", editable: true, type: "text", editor: RichTextEditor },
16 | { name: "departDate", editable: true, type: "text", editor: FullCalendarEditor },
17 | { name: "returnDate", editable: true, type: "text", editor: FullCalendarEditor }
18 | ],
19 | scrapePage: () => {
20 | const form = document.getElementById("gcw-packages-form-hp-package")
21 | return [
22 | {
23 | id: "1", // only one row so we can just hardcode an ID
24 | rowElements: [form],
25 | dataValues: {
26 | origin: form.querySelector("#package-origin-hp-package"),
27 | destination: form.querySelector("#package-destination-hp-package"),
28 | departDate: form.querySelector("#package-departing-hp-package"),
29 | returnDate: form.querySelector("#package-returning-hp-package")
30 | }
31 | }
32 | ]
33 | },
34 | // Reload data anytime the form changes or there's a click on the page
35 | addScrapeTriggers: (loadTable) => {
36 | document.addEventListener("click", e => loadTable())
37 |
38 | const form = document.getElementById("gcw-packages-form-hp-package")
39 | form.addEventListener("change", loadTable())
40 | }
41 | })
42 |
43 | export default ExpediaAdapter;
44 |
45 |
--------------------------------------------------------------------------------
/src/site_adapters/flux.ts:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | import { urlExact, urlContains, extractNumber, onDomReady } from "../utils";
4 | import { createDomScrapingAdapter, ScrapedRow } from "./domScrapingBase"
5 |
6 | const FluxAdapter = createDomScrapingAdapter({
7 | name: "Flux Valor Bruto Absorbância",
8 |
9 | enabled () {
10 | return urlContains("flux2.luar.dcc.ufmg.br/workflow")
11 | },
12 |
13 | attributes: [
14 | { name: "0", type: "numeric", editable: true},
15 | { name: "1", type: "numeric", editable: true},
16 | { name: "2", type: "numeric", editable: true},
17 | { name: "3", type: "numeric", editable: true},
18 | { name: "4", type: "numeric", editable: true},
19 | { name: "5", type: "numeric", editable: true},
20 | { name: "6", type: "numeric", editable: true},
21 | { name: "7", type: "numeric", editable: true},
22 | { name: "8", type: "numeric", editable: true},
23 | { name: "9", type: "numeric", editable: true},
24 | { name: "10", type: "numeric", editable: true},
25 | ],
26 |
27 | scrapePage() {
28 | let result = []
29 | let tbody = document.querySelector("div[xpdlid='valorBrutoAbs'] tbody")
30 |
31 | if (!tbody) { return null; }
32 |
33 | for (let i = 0; i < 8; i++) {
34 | const newScrapedRow:ScrapedRow =
35 | {
36 | id: String(i),
37 | rowElements: [tbody.children[i]],
38 | dataValues: {
39 | "0": document.querySelector(`#valorBrutoAbs--${i}--0`),
40 | "1": document.querySelector(`#valorBrutoAbs--${i}--1`),
41 | "2": document.querySelector(`#valorBrutoAbs--${i}--2`),
42 | "3": document.querySelector(`#valorBrutoAbs--${i}--3`),
43 | "4": document.querySelector(`#valorBrutoAbs--${i}--4`),
44 | "5": document.querySelector(`#valorBrutoAbs--${i}--5`),
45 | "6": document.querySelector(`#valorBrutoAbs--${i}--6`),
46 | "7": document.querySelector(`#valorBrutoAbs--${i}--7`),
47 | "8": document.querySelector(`#valorBrutoAbs--${i}--8`),
48 | "9": document.querySelector(`#valorBrutoAbs--${i}--9`),
49 | "10": document.querySelector(`#valorBrutoAbs--${i}--10`),
50 | }
51 | }
52 |
53 | result.push(newScrapedRow)
54 | }
55 |
56 | return result
57 | },
58 |
59 | addScrapeTriggers (loadTable) {
60 | // listen for input changes on the table
61 | const tbody = document.querySelector("div[xpdlid='valorBrutoAbs'] tbody")
62 | if (!tbody) return;
63 | tbody.addEventListener("change", (e) => { loadTable() });
64 | },
65 | });
66 |
67 | export default FluxAdapter;
68 |
--------------------------------------------------------------------------------
/src/site_adapters/github.ts:
--------------------------------------------------------------------------------
1 | 'use strict';
2 | import { urlMatches, extractNumber } from "../utils";
3 | import { createDomScrapingAdapter } from "./domScrapingBase"
4 |
5 | const GithubAdapter = createDomScrapingAdapter({
6 | name: "Github",
7 | enabled: () => false,
8 | attributes: [
9 | { name: "name" , type: "text"},
10 | { name: "stars" , type: "numeric"},
11 | { name: "forks" , type: "numeric"},
12 | { name: "updated", type: "text"},
13 | // TODO datetime type would be nice? not everything has ISO formatted strings
14 | ],
15 | scrapePage() {
16 | return Array.from(document.querySelectorAll("li.source")).map(el => {
17 | let name_el = el.querySelector('a[itemprop="name codeRepository"]')
18 | let name = name_el.textContent.trim()
19 |
20 | let stars_el = el.querySelector('*[href*="/stargazers"')
21 | let stars = extractNumber(stars_el, 0)
22 |
23 | let forks_el = el.querySelector('*[href*="/network/members"]')
24 | let forks = extractNumber(forks_el, 0)
25 |
26 | let lang_el = el.querySelector('*[itemprop="programmingLanguage"]')
27 | // some repos don't have language set
28 | let lang = lang_el == null ? null : lang_el.textContent.trim()
29 |
30 | let updated_el = el.querySelector('relative-time')
31 | let updated = updated_el.getAttribute('datetime')
32 |
33 | return {
34 | id: name,
35 | rowElements: [el],
36 | dataValues: {
37 | name: name,
38 | stars: stars,
39 | forks: forks,
40 | updated: updated,
41 | },
42 | }
43 | })
44 | },
45 | onRowSelected: (row) => {
46 | row.rowElements.forEach(el => {
47 | if (el.style) {
48 | el.style["background-color"] = "#def3ff"
49 | }
50 | });
51 | row.rowElements[0].scrollIntoView({ behavior: "smooth", block: "center" })
52 | },
53 | onRowUnselected: (row) => {
54 | row.rowElements.forEach(el => {
55 | if(el.style) {
56 | el.style["background-color"] = ``
57 | }
58 | })
59 | },
60 | })
61 |
62 | export default GithubAdapter;
63 |
--------------------------------------------------------------------------------
/src/site_adapters/hackerNews.ts:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | // A sample new HN site adapter.
4 |
5 | import { urlExact, urlContains, extractNumber } from "../utils";
6 | import { createDomScrapingAdapter } from "./domScrapingBase"
7 |
8 | // Configuration options for the Hacker News adapter
9 | const HNAdapter = createDomScrapingAdapter({
10 | name: "Hacker News",
11 | enabled () {
12 | return urlExact("news.ycombinator.com/") ||
13 | urlContains("news.ycombinator.com/news") ||
14 | urlContains("news.ycombinator.com/newest")
15 | },
16 | attributes: [
17 | { name: "id", type: "text", hidden: true },
18 | { name: "mainRow", type: "element" },
19 | { name: "detailsRow", type: "element" },
20 | { name: "rank", type: "numeric" },
21 | { name: "title", type: "element", formula: `=QuerySelector(mainRow, "a.storylink")` },
22 | { name: "link", type: "text", formula: `=GetAttribute(title, "href")` },
23 | { name: "points", type: "numeric", formula: `=QuerySelector(detailsRow, "span.score")` },
24 | { name: "user", type: "text" },
25 | { name: "comments", type: "numeric" }
26 | ],
27 | scrapePage() {
28 | return Array.from(document.querySelectorAll("tr.athing")).map(el => {
29 | let detailsRow = el.nextElementSibling
30 | let spacerRow = detailsRow.nextElementSibling
31 |
32 | return {
33 | id: String(el.getAttribute("id")),
34 | rowElements: [el, detailsRow, spacerRow],
35 | // todo: Both of these steps should be handled by the framework...
36 | // .filter(e => e) // Only include if the element is really there
37 | // .map(e => (e)), // Convert to HTMLElement type
38 | dataValues: {
39 | mainRow: el,
40 | detailsRow: detailsRow,
41 | rank: el.querySelector("span.rank"),
42 | title: el.querySelector("a.storylink"),
43 | link: el.querySelector("a.storylink").getAttribute("href"),
44 | // These elements contain text like "162 points";
45 | // Wildcard takes care of extracting a number automatically.
46 | points: detailsRow.querySelector("span.score"),
47 | user: detailsRow.querySelector("a.hnuser"),
48 | comments: extractNumber(Array.from(detailsRow.querySelectorAll("a"))
49 | .find(e => e.textContent.indexOf("comment") !== -1), 0)
50 | },
51 | annotationContainer: detailsRow.querySelector("td.subtext") as HTMLElement,
52 | annotationTemplate: `| $annotation `
53 | }
54 | })
55 | },
56 | onRowSelected: (row) => {
57 | row.rowElements.forEach(el => {
58 | if (el.style) {
59 | el.style["background-color"] = "#def3ff"
60 | }
61 | });
62 | row.rowElements[0].scrollIntoView({ behavior: "smooth", block: "center" })
63 | },
64 | onRowUnselected: (row) => {
65 | row.rowElements.forEach(el => {
66 | if(el.style) {
67 | el.style["background-color"] = ``
68 | }
69 | })
70 | },
71 | })
72 |
73 | export default HNAdapter;
74 |
--------------------------------------------------------------------------------
/src/site_adapters/harvardbookwarehouse.ts:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | import { urlContains } from "../utils";
4 | import { createDomScrapingAdapter } from "./domScrapingBase"
5 |
6 | // Configuration options for the Hacker News adapter
7 | const HarvardBookWarehouse = createDomScrapingAdapter({
8 | name: "Harvard Book Warehouse",
9 | enabled() {
10 | return urlContains("https://hbswarehousesale.com/")
11 | },
12 | attributes: [
13 | { name: "id", type: "text", hidden: true },
14 | { name: "title", type: "text" },
15 | { name: "priceRegular", type: "numeric" },
16 | { name: "priceSale", type: "numeric" },
17 | { name: "soldOut", type: "checkbox" },
18 | ],
19 | scrapePage() {
20 | return Array.from(document.querySelectorAll(".grid__item.grid__item--collection-template")).map(el => {
21 | return {
22 | id: el.querySelector("a.grid-view-item__link").getAttribute('href'),
23 | rowElements: [el],
24 | dataValues: {
25 | title: el.querySelector("a.grid-view-item__link span.visually-hidden"),
26 | priceRegular: el.querySelector(".price__regular .price-item--regular"),
27 | priceSale: el.querySelector(".price__sale .price-item--regular"),
28 | soldOut: el.querySelector(".price__badge--sold-out") &&
29 | window.getComputedStyle(el.querySelector(".price__badge--sold-out")).getPropertyValue('display') !== 'none'
30 | },
31 | }
32 | })
33 | },
34 | })
35 |
36 | export default HarvardBookWarehouse;
37 |
--------------------------------------------------------------------------------
/src/site_adapters/index.ts:
--------------------------------------------------------------------------------
1 | // Registry of all the site adapters
2 |
3 | import HNAdapter from './hackerNews'
4 | import FluxAdapter from './flux'
5 | // expedia adapter has a css issue with esbuild
6 | // import ExpediaAdapter from './expedia'
7 | import AirbnbAdapter from './airbnb'
8 | import AmazonAdapter from './amazon'
9 | import InstacartAdapter from './instacart'
10 | import UberEatsAdapter from './ubereats'
11 | import BloggerAdapter from './blogger'
12 | import WeatherChannelAdapter from './weatherchannel'
13 | import YoutubeAdapter from './youtube'
14 | import GithubAdapter from './github'
15 | import HarvardBookWarehouse from './harvardbookwarehouse'
16 | import { adapterStore } from '../localStorageAdapter'
17 | import { TableAdapter } from '../core/types'
18 | import { createInitialAdapter } from '../end_user_scraper/adapterHelpers'
19 | import { getCreatingAdapter } from '../end_user_scraper/state'
20 |
21 | export const siteAdapters = [
22 | HNAdapter,
23 | FluxAdapter,
24 | // ExpediaAdapter,
25 | AirbnbAdapter,
26 | AmazonAdapter,
27 | InstacartAdapter,
28 | UberEatsAdapter,
29 | BloggerAdapter,
30 | WeatherChannelAdapter,
31 | YoutubeAdapter,
32 | GithubAdapter,
33 | HarvardBookWarehouse
34 | ]
35 |
36 | export async function getActiveAdapter(): Promise {
37 | const creatingAdapter = getCreatingAdapter();
38 | const localAdapters = await adapterStore.getLocalAdapters();
39 | const adaptersForPage = [
40 | ...localAdapters,
41 | ...siteAdapters
42 | ].filter(adapter => adapter.enabled())
43 |
44 | let activeAdapter;
45 |
46 | if (adaptersForPage.length === 0 && !creatingAdapter) {
47 | return undefined;
48 | } else if (creatingAdapter) {
49 | activeAdapter = createInitialAdapter();
50 | } else {
51 | activeAdapter = adaptersForPage[0];
52 | }
53 |
54 | console.log(`Wildcard: activating site adapter: ${activeAdapter.name}`);
55 |
56 | return activeAdapter;
57 | }
58 |
--------------------------------------------------------------------------------
/src/site_adapters/instacart.ts:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | import {urlContains} from "../utils";
4 | import { createDomScrapingAdapter } from "./domScrapingBase"
5 |
6 | export const InstacartAdapter = createDomScrapingAdapter({
7 | name: "Instacart",
8 | enabled: () => {
9 | return urlContains("instacart.com/store/orders")
10 | },
11 | attributes: [
12 | // { name: "id", type: "text" },
13 | { name: "name", type: "text" },
14 | { name: "price", type: "numeric" },
15 | { name: "quantity", type: "numeric" },
16 | ],
17 | scrapePage: () => {
18 | return Array.from(document.querySelectorAll("li.order-status-item")).map (el => {
19 | const itemName = el.querySelector("div.order-status-item-details h5").textContent;
20 | const itemPrice = el.querySelector("div.order-status-item-price p").textContent.substring(1);
21 |
22 | let itemQuantity = null;
23 | let quantityDropdown = el.querySelector("div.icDropdown button span");
24 | let quantityText = el.querySelector("div.order-status-item-qty p");
25 | if (quantityDropdown) {
26 | itemQuantity = quantityDropdown.textContent;
27 | } else if (quantityText) {
28 | itemQuantity = quantityText.textContent;
29 | }
30 |
31 | return {
32 | id: itemName,
33 | rowElements: [el],
34 | dataValues: {
35 | name: itemName,
36 | price: itemPrice,
37 | quantity: itemQuantity
38 | }
39 | }
40 | })
41 | },
42 | // Reload data anytime the form changes or there's a click on the page
43 | addScrapeTriggers: (loadTable) => {
44 | document.addEventListener("click", e => loadTable())
45 | }
46 | });
47 |
48 | export default InstacartAdapter;
49 |
--------------------------------------------------------------------------------
/src/site_adapters/ubereats.ts:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | import { extractNumber, urlExact, urlContains } from "../utils"
4 | import { createDomScrapingAdapter } from "./domScrapingBase"
5 |
6 | const UberEatsAdapter = createDomScrapingAdapter({
7 | name: "Uber Eats",
8 | enabled: () => {
9 | return urlContains("ubereats.com")
10 | },
11 | attributes: [
12 | { name: "id", type: "text", hidden: true},
13 | { name: "name", type: "text" },
14 | { name: "eta", type: "text" },
15 | { name: "categories", type: "text" },
16 | { name: "price_bucket", type: "text" },
17 | { name: "rating", type: "numeric" },
18 | { name: "fee", type: "numeric" },
19 | {name: "is_open", type: "text"}
20 | ],
21 | scrapePage: () => {
22 | return Array.from(document.querySelectorAll("a")).map(el => {
23 | var prefix;
24 |
25 | //check that el has restaurant
26 | if (el.getAttribute("href").includes("food-delivery/") == true){
27 |
28 | var url_elts = el.getAttribute("href").split("/");
29 | var title = url_elts[url_elts.length-2];
30 | console.log(title);
31 |
32 | var restaurant_el = el.childNodes[0];
33 | var restaurant = restaurant_el.textContent;
34 |
35 | return {
36 | id: title.toString(),
37 | rowElements: [el],
38 | dataValues: {
39 | name: restaurant
40 | },
41 | }
42 |
43 | }
44 | }).filter(row => row != undefined);
45 | },
46 |
47 | scrapeAjax: (request) => {
48 | if(request.url.includes("https://www.ubereats.com/api/getFeedV1")){
49 | try{
50 | let listings = request.data.data.storesMap;
51 |
52 | return Object.keys(listings).map(key => {
53 | let listing = listings[key];
54 | let l_eta = "Unavailable";
55 | let l_categories = "Unavailable";
56 | let l_price_bucket = "Unavailable";
57 | let l_rating = 0;
58 | let l_fee = 0;
59 |
60 | if (!(listing.etaRange == null)){
61 | l_eta = listing.etaRange.text;
62 | }
63 |
64 | if (!(listing.meta.categories == null)){
65 | l_categories = listing.meta.categories;
66 | }
67 |
68 | if (!(listing.meta.priceBucket == null)){
69 | l_price_bucket = listing.meta.priceBucket;
70 | }
71 |
72 | if (!(listing.meta.deliveryFee == null)){
73 | l_fee = listing.meta.deliveryFee.text.split(" ")[0];
74 | }
75 |
76 | if (!(listing.feedback == null)){
77 | l_rating = listing.feedback.rating;
78 | }
79 |
80 | return {
81 | id: listing.slug.toString(),
82 | dataValues: {
83 | eta: l_eta,
84 | categories: l_categories,
85 | price_bucket: l_price_bucket,
86 | rating: l_rating,
87 | fee: l_fee,
88 | is_open: listing.isOpen.toString()
89 | }
90 | }
91 | }).filter(row => row.dataValues.is_open === "true");
92 | }
93 | catch(e){
94 | console.log(e);
95 | }
96 | }
97 | return undefined;
98 | },
99 | // Reload data anytime there's a click or keypress on the page
100 | addScrapeTriggers: (reload) => {
101 | document.addEventListener("click", (e) => {
102 | console.log("clicked");
103 | reload() });
104 | document.addEventListener("keydown", (e) => { reload() });
105 | }
106 | });
107 |
108 | export default UberEatsAdapter;
109 |
--------------------------------------------------------------------------------
/src/site_adapters/weatherchannel.ts:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | import { extractNumber, urlExact, urlContains } from "../utils"
4 | import { createDomScrapingAdapter } from "./domScrapingBase"
5 | import * as moment from 'moment';
6 |
7 | const WeatherChannelAdapter = createDomScrapingAdapter({
8 | name: "Weather Channel",
9 | enabled: () => {
10 | return urlContains("https://weather.com/weather/hourbyhour")
11 | },
12 | attributes: [
13 | { name: "id", type: "text", hidden: true },
14 | { name: "Time", type: "time", timeFormat: 'h:mm:ss a',
15 | correctFormat: true },
16 | { name: "Description", type: "text"},
17 | { name: "Temp °F", type: "numeric"},
18 | { name: "Feels °F", type: "numeric"},
19 | { name: "Precip %", type: "numeric"},
20 | { name: "Humidity %", type: "numeric"},
21 | { name: "Wind", type: "text"}
22 | ],
23 | scrapePage: () => {
24 | let tableRows = document.querySelector('.twc-table').querySelectorAll("tr");
25 | //tableRows includes the heading, so we don't want to include that
26 | let arrayOfRows = Array.from(tableRows);
27 | arrayOfRows.shift();
28 | return arrayOfRows.map(el => {
29 | return {
30 | rowElements: [el],
31 | id: el.querySelector('.dsx-date').textContent,
32 | dataValues: {
33 | Time: el.querySelector('.dsx-date'),
34 | Description: el.querySelector('.description').children[0],
35 | 'Temp °F': el.querySelector('.temp').children[0],
36 | 'Feels °F': el.querySelector('.feels').children[0],
37 | 'Precip %': el.querySelector('.precip').children[0],
38 | 'Humidity %': el.querySelector('.humidity').children[0],
39 | Wind: el.querySelector('.wind').children[0],
40 | },
41 | }
42 | })
43 | },
44 | // Reload data anytime there's a click or keypress on the page
45 | addScrapeTriggers: (reload) => {
46 | document.addEventListener("click", (e) => { reload() });
47 | document.addEventListener("keydown", (e) => { reload() });
48 | }
49 | });
50 |
51 | export default WeatherChannelAdapter;
52 |
53 |
--------------------------------------------------------------------------------
/src/site_adapters/youtube.ts:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | import { extractNumber, urlExact, urlContains } from "../utils"
4 | import { createDomScrapingAdapter } from "./domScrapingBase"
5 | import debounce from 'lodash/debounce'
6 |
7 |
8 | const YoutubeAdapter = createDomScrapingAdapter({
9 | name: "YouTube",
10 | enabled: () => {
11 | return urlContains("youtube.com")
12 | },
13 | attributes: [
14 | { name: "id", type: "text", hidden: true },
15 | { name: "Title", type: "text" },
16 | { name: "Time", type: "text"},
17 | { name: "Uploader", type: "text"},
18 | { name: "% Watched", type: "numeric"}
19 | ],
20 | scrapePage: () => {
21 | let tableRows = document.querySelector('#contents').children;
22 | if (tableRows.length == 1) {
23 | // for use on video listing page e.g. https://www.youtube.com/user/*/videos
24 | tableRows = document.querySelector('#contents #items').children;
25 | }
26 | return Array.from(tableRows).map((el, index) => {
27 | let elAsHTMLElement : HTMLElement = el;
28 |
29 | // on /user/*/videos, link is in #thumbnail, not #video-title-link
30 | if((el.querySelector('#video-title-link') !== null || el.querySelector('#thumbnail') !== null) && el.querySelector('#overlays') != null && el.querySelector('#overlays').children[0] != null){
31 |
32 | let overlayChildrenAmount = el.querySelector('#overlays').children.length;
33 | let timeStampExists = overlayChildrenAmount > 1 && el.querySelector('#overlays').children[overlayChildrenAmount - 2].children[1] !== undefined;
34 | let timeStamp = timeStampExists
35 | ? el.querySelector('#overlays').children[overlayChildrenAmount - 2].children[1].textContent.replace((/ |\r\n|\n|\r/gm),"")
36 | : "N/A";
37 | let watchedPercentage = el.querySelector('#progress') !== null
38 | ? progressToNumber((el.querySelector('#progress') as HTMLElement).style.width)
39 | : 0;
40 |
41 | return {
42 | rowElements: [elAsHTMLElement],
43 | id: (el.querySelector('#video-title-link') || el.querySelector('#thumbnail')).getAttribute("href"),
44 | dataValues: {
45 | Title: el.querySelector('#video-title'),
46 | Time: timeStamp,
47 | Uploader: el.querySelector('#text').children[0],
48 | '% Watched': watchedPercentage,
49 | },
50 | }
51 | }
52 | else
53 | {
54 | return null;
55 | }
56 |
57 | }).filter(el => el !== null)
58 | },
59 | // Reload data anytime there's a click or keypress on the page
60 | addScrapeTriggers: (reload) => {
61 | document.addEventListener("click", (e) => { reload() });
62 | document.addEventListener("keydown", (e) => { reload() });
63 | document.addEventListener("scroll", debounce((e) => { reload() }, 50));
64 | },
65 | onRowSelected: (row) => {
66 | row.rowElements.forEach(el => {
67 | if (el.style) {
68 | el.style["background-color"] = `#c9ebff`
69 | }
70 | });
71 | row.rowElements[0].scrollIntoView({ behavior: "smooth", block: "center" })
72 | },
73 | onRowUnselected: (row) => {
74 | row.rowElements.forEach(el => {
75 | if(el.style) {
76 | el.style["background-color"] = ``
77 | }
78 | })
79 | },
80 | });
81 |
82 | function progressToNumber(progress){
83 | let strippedProgress = progress.slice(0, -1);
84 | return parseInt(strippedProgress);
85 | }
86 |
87 | export default YoutubeAdapter;
88 |
--------------------------------------------------------------------------------
/src/tableAdapterMiddleware.ts:
--------------------------------------------------------------------------------
1 | // When things happen to our Redux state,
2 | // which we want to propagate downstream to the adapter,
3 | // we handle it here.
4 |
5 | // (when adapters are _upstream_ of the state,
6 | // handle it in action creators instead.)
7 |
8 | import { getFinalRecords } from './core/getFinalTable'
9 | import { TableAdapter } from './core/types'
10 | import pick from 'lodash/pick'
11 |
12 | export const TableAdapterMiddleware = (tableAdapter: TableAdapter) =>
13 | ({ getState }) => next => action => {
14 |
15 | // Call the next dispatch method in the middleware chain.
16 | const returnValue = next(action)
17 |
18 | const newState = getState();
19 |
20 | // todo: should we just do this on every state update?
21 | // all we get by being picky is perf improvement;
22 | // we're sending in the whole new state each time anyway?
23 | // actually nvm... maybe we do gain a lot of perf by telling the adapter
24 | // where the edit happened rather than just "something changed"
25 | switch (action.type) {
26 | // Records were sorted;
27 | // apply the sort to this adapter
28 | case "SORT_RECORDS":
29 | tableAdapter.applySort(
30 | getFinalRecords(newState),
31 | newState.sortConfig
32 | );
33 | break;
34 |
35 | // Notify this table store if another table was reloaded
36 | // (eg: notify the site that user data has changed,
37 | // so we need to update annotations)
38 | case "TABLE_RELOADED":
39 | if (action.table.tableId !== tableAdapter.tableId) {
40 | tableAdapter.handleOtherTableUpdated(action.table)
41 | }
42 | break;
43 |
44 | // update the website with annotations from formula results.
45 | // todo: we're baking in some notions of formulas being only in the user table here...
46 | // should rethink a design where formulas can occur anywhere?
47 | case "FORMULAS_EVALUATED":
48 | const finalRecords = getFinalRecords(newState)
49 | const userAttributeNames = newState.userTable.attributes.map(a => a.name)
50 | const userRecordsWithFormulaResults =
51 | finalRecords.map(record => ({...record, values: pick(record.values, userAttributeNames)}))
52 |
53 | tableAdapter.handleOtherTableUpdated(
54 | {...newState.userTable,
55 | records: userRecordsWithFormulaResults})
56 | break;
57 |
58 | case "RECORD_SELECTED":
59 | if (action.recordId) {
60 | tableAdapter.handleRecordSelected(action.recordId, action.attribute);
61 | }
62 |
63 | }
64 |
65 | return returnValue
66 | }
67 |
68 |
--------------------------------------------------------------------------------
/src/ui/AutosuggestInput.tsx:
--------------------------------------------------------------------------------
1 | import React, {useState} from "react";
2 | import Autosuggest from 'react-autosuggest';
3 | import {functions} from '../formula'
4 |
5 | const autosuggestTheme = {
6 | container: {
7 | display: 'inline-block',
8 | position: 'relative',
9 | minWidth: '50%',
10 | marginLeft: '10px',
11 | zIndex: '2500',
12 | color: 'black',
13 | fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"',
14 | fontSize: '12px',
15 | },
16 | input: {
17 | padding: '5px',
18 | border: 'solid thin #ddd',
19 | height: '1.5em',
20 | width: '100%',
21 | fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"',
22 | fontSize: '14px',
23 | '&:focus': {
24 | border: 'none'
25 | }
26 | },
27 | suggestionsContainer: {
28 | display: 'none',
29 | },
30 | suggestionsContainerOpen: {
31 | display: 'block',
32 | position: 'absolute',
33 | width: '100%',
34 | minHeight: '80px',
35 | maxHeight: '240px',
36 | background: 'rgba(255,255,255,0.8)',
37 | boxShadow: '0px 0px 3px gray',
38 | overflowY: 'auto',
39 | },
40 | suggestionsList: {
41 | padding: '0px',
42 | margin: '0px',
43 | listStyleType: 'none',
44 | },
45 | suggestion: {
46 | margin: '0',
47 | padding: '0px 5px',
48 | cursor: 'pointer'
49 | },
50 | suggestionHighlighted: {
51 | background: 'rgb(200,200,200,0.4)',
52 | },
53 | sectionContainer: {
54 | padding: '0px 5px',
55 | borderTop: '1px solid #ccc'
56 | },
57 | sectionTitle: {
58 | fontVariantCaps: 'all-small-caps',
59 | fontSize: '10px',
60 | color: 'gray'
61 | }
62 | };
63 |
64 | const AutosuggestInput = ({activeCellValue, setActiveCellValue, suggestions, setSuggestions, cellEditorRef, attributes, onCellEditorKeyPress, commitActiveCellValue}) => {
65 | const [prefix, setPrefix] = useState('')
66 |
67 | // This pattern matches every thing after and including any one of these symbols: = ( + - * / ,
68 | const regex = /[=(\+\-\*\/,][^=(\+\-\*\/,]*$/;
69 |
70 | // Teach Autosuggest how to calculate suggestions for any given input value.
71 | const attributeNames = attributes.map(attribute => attribute.name);
72 | const mathSymbols = {"Plus": "+", "Minus": "-", "Multiply": "*", "Divide": "/"};
73 | const mathSuggestions = Object.keys(functions)
74 | .filter(functionName => Object.keys(mathSymbols).indexOf(functionName) !== -1)
75 | .reduce((obj, functionName) => {
76 | const symbol = mathSymbols[functionName];
77 | obj[symbol] = functions[functionName];
78 | return obj;
79 | }, {});
80 | const allFunctionNames = Object.keys(functions)
81 | .sort()
82 | .filter(functionName => Object.keys(mathSymbols).indexOf(functionName) === -1)
83 | const allSuggestions = [
84 | {title: "Functions", suggestions: allFunctionNames},
85 | {title: "Columns", suggestions: attributeNames}
86 | ]
87 |
88 | const getSuggestions = value => {
89 | const inputValue = value.trim()
90 | const matchIndex = inputValue.search(regex);
91 |
92 | // save the prefix of the input (everything up to the suggestion)
93 | const curPrefix = value.slice(0, matchIndex+1);
94 | if (curPrefix !== prefix) {
95 | setPrefix(curPrefix);
96 | }
97 |
98 | if (matchIndex === inputValue.length - 1) {
99 | // If at the start of a new expression, include all possible suggestions
100 | return allSuggestions;
101 | }
102 | else if (matchIndex === -1) {
103 | return [];
104 | }
105 | else {
106 | // Use everything after the regex match index (matchValue) as the prefix to determine suggestions
107 | const matchValue = inputValue.slice(matchIndex+1, inputValue.length).toLowerCase().trim();
108 | let filteredSuggestions = allSuggestions
109 | .map(section => {
110 | return {
111 | title: section.title,
112 | suggestions: section.suggestions.filter(suggestion => suggestion.toLowerCase().slice(0, matchValue.length) === matchValue)
113 | };
114 | })
115 | .filter(section => section.suggestions.length > 0);
116 | // Math operations are suggested after a numeric attribute name or after ")"
117 | // const attributeIndex = attributeNames.indexOf(matchValue);
118 | // const isNumericAttribute = attributeIndex != -1 && attributes[attributeIndex].type === "numeric";
119 | // const lastChar = matchValue[matchValue.length - 1];
120 | // if (isNumericAttribute || lastChar === ")"){
121 | // suggestions = suggestions.concat(Object.keys(mathSuggestions))
122 | // }
123 | return filteredSuggestions;
124 | }
125 | };
126 |
127 | const getSuggestionValue = function(suggestion) {
128 | return prefix[prefix.length-1] === "," ? prefix + " " + suggestion : prefix + suggestion;
129 | }
130 |
131 | // Determine how individual suggestions are rendered into HTML.
132 | const renderSuggestion = function(suggestion, {query}) {
133 | return({suggestion}
)
134 | }
135 |
136 | // Render helper text for functions in the footer
137 | const renderSuggestionsContainer = function({ containerProps, children }) {
138 | let inputValue = activeCellValue.toString().trim()
139 | const lastChar = inputValue[inputValue.length-1]
140 |
141 | if (lastChar === "(" || lastChar === ",") {
142 | inputValue = inputValue.slice(0, inputValue.lastIndexOf("("))
143 | }
144 |
145 | const matchIndex = inputValue.search(regex);
146 | const matchValue = inputValue.slice(matchIndex+1, inputValue.length).trim();
147 | const attributeIndex = attributeNames.indexOf(matchValue);
148 |
149 | let footer = undefined;
150 | if (matchValue in functions) { // function
151 | const params = functions[matchValue]["help"];
152 | footer = (
153 |
154 | {matchValue + "("}
{Object.keys(params).join(", ")} {")"}
155 |
{Object.keys(params).map(key =>
{key} : {params[key]}
)}
156 |
157 | )
158 | }
159 | else if (lastChar in mathSuggestions) { // math symbol
160 | const helpText = mathSuggestions[lastChar]["help"];
161 | footer = (
162 |
163 | numeric1
{lastChar} numeric2
164 |
{helpText}
165 |
166 | )
167 | }
168 | else if (attributeIndex != -1) { // attribute name
169 | const attribute = attributes[attributeIndex];
170 | footer = (
171 |
172 | {attribute.name} is a column with type {attribute.type} .
173 |
174 | )
175 | }
176 | return (
177 |
178 | {children}
179 |
180 |
181 |
Documentation
182 | {footer}
183 |
184 |
185 |
186 | )
187 | }
188 |
189 | const onChange = function(event, { newValue }) {
190 | setActiveCellValue(newValue);
191 | }
192 |
193 | // Autosuggest will call this function every time we need to update suggestions.
194 | const onSuggestionsFetchRequested = function({ value }) {
195 | setSuggestions(getSuggestions(value));
196 | };
197 |
198 | // Autosuggest will call this function every time we need to clear suggestions.
199 | const onSuggestionsClearRequested = function() {
200 | setSuggestions([]);
201 | };
202 |
203 | const renderSectionTitle = function(section) {
204 | return {section.title} ;
205 | }
206 |
207 | const getSectionSuggestions = function(section) {
208 | return section.suggestions;
209 | }
210 |
211 | return
230 | }
231 |
232 | export default AutosuggestInput;
--------------------------------------------------------------------------------
/src/ui/WcPanel.tsx:
--------------------------------------------------------------------------------
1 | import React, { useRef, useState } from "react";
2 | import { HotTable } from '@handsontable/react';
3 | import "handsontable/dist/handsontable.full.css";
4 | import "./overrides.css";
5 | import styled from 'styled-components'
6 | import { Record, Attribute } from '../core/types'
7 | import Handsontable from "handsontable";
8 | import { FormulaEditor } from '../ui/cell_editors/formulaEditor';
9 | import mapValues from 'lodash/mapValues'
10 | import AutosuggestInput from './AutosuggestInput'
11 | import { getCreatingAdapter, setCreatingAdapter } from "../end_user_scraper/state";
12 |
13 | const marketplaceUrl = "https://wildcard-marketplace.herokuapp.com";
14 |
15 | function formatRecordsForHot(records:Array) {
16 | return records.map(record => ({
17 | id: record.id,
18 | ...mapValues(record.values, v => v instanceof HTMLElement ? v.textContent : v)
19 | }))
20 | }
21 |
22 | function formatAttributesForHot(attributes:Array) {
23 | return attributes.map(attribute => ({
24 | data: attribute.name,
25 |
26 | // If it's an "element" attribute, just render it as text
27 | type: attribute.type === "element" ? "text" : attribute.type,
28 | readOnly: !attribute.editable,
29 | editor: attribute.editor
30 | }))
31 | }
32 |
33 | const ToggleButton = styled.div`
34 | display: block;
35 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
36 | font-size: 14px;
37 | border-radius: 10px;
38 | z-index: 10000;
39 | padding: 10px;
40 | position: fixed;
41 | bottom: ${props => props.hidden ? 20 : 300}px;
42 | right: ${props => props.codeEditorHidden ? 2 : 31}vw;
43 | background-color: white;
44 | box-shadow: 0px 0px 10px -1px #d5d5d5;
45 | border: none;
46 | cursor: pointer;
47 | &:hover {
48 | background-color: #eee;
49 | }
50 | `
51 |
52 | const Panel = styled.div`
53 | position: fixed;
54 | bottom: 0;
55 | left: 0;
56 | height: ${props => props.hidden ? 0 : 280}px;
57 | width: ${props => props.codeEditorHidden ? 98 : 68.5}vw;
58 | z-index: 2200;
59 | box-shadow: 0px -5px 10px 1px rgba(170,170,170,0.5);
60 | border-top: solid thin #9d9d9d;
61 | overflow: hidden;
62 | background-color: white;
63 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
64 | font-size: 14px;
65 | `
66 |
67 | const ControlBar = styled.div`
68 | height: 30px;
69 | padding: 5px 10px;
70 | `
71 |
72 | const EditorButton = styled(ToggleButton)`
73 | display: ${props => props.codeEditorHidden ? 'none' : 'block'};
74 | bottom: 20px;
75 | right: ${props => props.right};
76 | `
77 |
78 | const ShareButton = styled(ToggleButton)`
79 | /* right: ${props => props.right}; */
80 | right: calc(2vw + 165px);
81 | display: ${props => props.hidden || !props.codeEditorHidden ? 'none' : 'block'};
82 | `
83 |
84 | const EditButton = styled(ToggleButton)`
85 | /* right: ${props => props.right}; */
86 | right: calc(2vw + 180px);
87 | display: ${props => props.hidden || !props.codeEditorHidden ? 'none' : 'block'};
88 | `
89 |
90 | // Declare our functional React component
91 |
92 | const WcPanel = ({ records = [], attributes, query, actions, adapter }) => {
93 | const creatingAdapter = getCreatingAdapter();
94 | const hotRef = useRef(null);
95 | const cellEditorRef = useRef(null);
96 | const [hidden, setHidden] = useState(false);
97 | // Declare a new state variable for adapter code
98 | const [adapterCode, setAdapterCode] = useState("");
99 | const [codeEditorHidden, setCodeEditorHidden] = useState(true);
100 | const _adapterKey = "localStorageAdapter:adapters:" + adapter.name;
101 |
102 |
103 | // Keep track of the currently selected cell
104 | const [activeCell, setActiveCell] = useState(null)
105 |
106 | // The value of the selected cell.
107 | // (Including in-progress updates that we are making in the UI)
108 | const [activeCellValue, setActiveCellValue] = useState('')
109 |
110 | // Autosuggest suggestions
111 | const [suggestions, setSuggestions] = useState([]);
112 |
113 | const onCellEditorKeyPress = (e) => {
114 | const key = e.key
115 | if (key === 'Enter') {
116 | cellEditorRef.current.blur()
117 | }
118 | }
119 |
120 | const commitActiveCellValue = () => {
121 | if(activeCellValue[0] === "=") {
122 | actions.setFormula(
123 | activeCell.attribute.tableId,
124 | activeCell.attribute.name,
125 | activeCellValue
126 | )
127 | } else {
128 | actions.editRecords([
129 | {
130 | tableId: activeCell.attribute.tableId,
131 | recordId: activeCell.record.id,
132 | attribute: activeCell.attribute.name,
133 | value: activeCellValue
134 | }
135 | ])
136 | }
137 | }
138 |
139 | const hotSettings = {
140 | data: formatRecordsForHot(records),
141 | rowHeaders: true,
142 | columns: formatAttributesForHot(attributes),
143 | colHeaders: attributes.map(attr => {
144 | if(attr.formula) {
145 | return ``
146 | } else if(attr.type === "element") {
147 | return ``
148 | } else {
149 | return ``
150 | }
151 | }),
152 | columnSorting: true,
153 |
154 | // Set a low column width,
155 | // then let HOT stretch out the columns to match the width
156 | width: "100%",
157 | colWidths: attributes.map(a => 100),
158 | stretchH: "all" as const,
159 | wordWrap: false,
160 | manualColumnResize: true,
161 |
162 | // todo: parameterize height, make whole panel stretchable
163 | height: 250,
164 |
165 | cells: (row, col, prop) => {
166 | const cellProperties:any = {}
167 | const attr = attributes.find(a => a.name === prop)
168 | if (attr.formula) {
169 | cellProperties.formula = attr.formula
170 | cellProperties.editor = FormulaEditor
171 | cellProperties.placeholder = "loading..."
172 | }
173 | return cellProperties
174 | },
175 |
176 | hiddenColumns: {
177 | columns: attributes.map((attr, idx) => attr.hidden ? idx : null).filter(e => Number.isInteger(e))
178 | },
179 | // contextMenu: {
180 | // items: {
181 | // "insert_user_attribute": {
182 | // name: 'Insert User Column',
183 | // callback: function(key, selection, clickEvent) {
184 | // // TODO: For now, new columns always get added to the user table.
185 | // // Eventually, do we want to allow adding to the main site table?
186 | // // Perhaps that'd be a way of extending scrapers using formulas...
187 | // actions.addAttribute("user");
188 | // }
189 | // },
190 | // "rename_user_attribute": {
191 | // // todo: disable this on site columns
192 | // name: 'Rename column',
193 | // callback: function(key, selection, clickEvent) {
194 | // alert('not implemented yet');
195 | // }
196 | // },
197 | // "clear_user_table": {
198 | // name: 'Clear user columns',
199 | // callback: function(key, selection, clickEvent) {
200 | // // TODO: For now, new columns always get added to the user table.
201 | // // Eventually, do we want to allow adding to the main site table?
202 | // // Perhaps that'd be a way of extending scrapers using formulas...
203 | // actions.clear("user");
204 | // }
205 | // },
206 | // "toggle_column_visibility":{
207 | // name: 'Show/hide column in page',
208 | // disabled: () => {
209 | // // only allow toggling visibility on user table
210 | // const colIndex = getHotInstance().getSelectedLast()[1]
211 | // const attribute = attributes[colIndex]
212 |
213 | // return attribute.tableId !== "user"
214 | // },
215 | // callback: function(key, selection, clickEvent) {
216 | // const attribute = attributes[selection[0].start.col];
217 |
218 | // // NOTE! idx assumes that id is hidden.
219 | // actions.toggleVisibility(attribute.tableId, attribute.name);
220 | // }
221 | // },
222 | // }
223 | // }
224 | }
225 |
226 | // Get a pointer to the current handsontable instance
227 | const getHotInstance = () => {
228 | if (hotRef && hotRef.current) { return hotRef.current.hotInstance; }
229 | else { return null; }
230 | }
231 |
232 | // make sure the HOT reflects the current sort config
233 | // of the query in our redux state.
234 | // (usually the sort config will be set from within HOT,
235 | // but this is needed e.g. to tell HOT when we load sort state from
236 | // local storage on initial pageload)
237 | const updateHotSortConfig = () => {
238 | if (getHotInstance()) {
239 | const columnSortPlugin = getHotInstance().getPlugin('columnSorting');
240 |
241 | let newHotSortConfig;
242 |
243 | if (query.sortConfig) {
244 | newHotSortConfig = {
245 | column: attributes.map(a => a.name).indexOf(query.sortConfig.attribute),
246 | sortOrder: query.sortConfig.direction
247 | };
248 | } else {
249 | newHotSortConfig = undefined;
250 | }
251 | columnSortPlugin.setSortConfig(newHotSortConfig);
252 | }
253 | }
254 |
255 | // todo: don't define these handlers inside the render funciton?
256 | // define outside and parameterize on props?
257 |
258 | // Handle user sorting the table
259 | const onBeforeColumnSort = (_, destinationSortConfigs) => {
260 | const columnSortPlugin = getHotInstance().getPlugin('columnSorting');
261 | // We suppress HOT's built-in sorting by returning false,
262 | // and manually tell HOT that we've taken care of
263 | // sorting the table ourselves.
264 | // https://handsontable.com/docs/7.4.2/demo-sorting.html#custom-sort-implementation
265 | columnSortPlugin.setSortConfig(destinationSortConfigs);
266 |
267 | // for the moment we only support single column sort
268 | const sortConfig = destinationSortConfigs[0];
269 |
270 | if (sortConfig) {
271 | actions.sortRecords({
272 | // Sort config gives us a numerical index; convert to attribute
273 | attribute: attributes[sortConfig.column].name,
274 | direction: sortConfig.sortOrder
275 | });
276 | } else {
277 | actions.sortRecords(null);
278 | }
279 |
280 | // don't let HOT sort the table
281 | return false;
282 | }
283 |
284 | // Handle user making a change to the table.
285 | // Similar to sorting, we suppress HOT built-in behavior, and
286 | // we handle the edit ourselves by triggering an action and
287 | // eventually rendering a totally fresh table from scratch
288 | const onBeforeChange = (changes, source) => {
289 | const edits = changes.map(([rowIndex, propName, prevValue, nextValue]) => {
290 | const attribute = attributes.find(a => a.name === propName)
291 |
292 | return {
293 | tableId: attribute.tableId,
294 | recordId: records[rowIndex].id,
295 | attribute: attribute.name,
296 | value: nextValue
297 | }
298 | })
299 |
300 | const dataEdits = edits.filter(e => e.value[0] !== "=")
301 | const formulaEdits = edits.filter(e => e.value[0] === "=")
302 |
303 | console.log({dataEdits, formulaEdits})
304 |
305 | actions.editRecords(dataEdits);
306 |
307 | for (const formulaEdit of formulaEdits) {
308 | actions.setFormula(
309 | formulaEdit.tableId,
310 | formulaEdit.attribute,
311 | formulaEdit.value
312 | )
313 | }
314 |
315 | // don't let HOT edit the value
316 | return false;
317 | }
318 |
319 | const onAfterSelection = (rowIndex, prop) => {
320 | const record = records[rowIndex]
321 | const attribute = attributes.find(attr => attr.name === prop)
322 |
323 | actions.selectRecord(record.id, prop)
324 |
325 | setActiveCell({ record, attribute })
326 |
327 | let activeCellValue
328 |
329 | if (attribute.formula) {
330 | activeCellValue = attribute.formula
331 | } else if (attribute.type === "element") {
332 | activeCellValue = record.values[attribute.name].outerHTML
333 | } else {
334 | activeCellValue = record.values[attribute.name] || ""
335 | }
336 | setActiveCellValue(activeCellValue)
337 | }
338 |
339 |
340 | const loadAdapterCode = function () {
341 | let loaded = false;
342 | // setup listener
343 | chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
344 | switch (request.command) {
345 | case 'openCodeEditor':
346 | // show code editor
347 | setCodeEditorHidden(false);
348 | sendResponse({ codeEditorHidden: false });
349 |
350 | // load adapter code
351 | if (!loaded) {
352 | loaded = true;
353 | chrome.storage.local.get(_adapterKey, (results) => {
354 | setAdapterCode(results[_adapterKey]);
355 | console.log("loaded code from storage");
356 | });
357 | }
358 | break;
359 | default:
360 | break;
361 | }
362 | });
363 | }
364 |
365 | const onBlurCodeEditor = function(e, code){
366 | const data = code.getValue();
367 | console.log('Editor Data: ',data);
368 | setAdapterCode(data);
369 | }
370 |
371 | const saveAdapterCode = function() {
372 | chrome.storage.local.set({ [_adapterKey]: adapterCode }, function() {
373 | console.log("saved changes");
374 | });
375 | }
376 | return <>
377 | {!creatingAdapter && (
378 | <>
379 | {
381 | setCreatingAdapter(true);
382 | chrome.runtime.sendMessage({ command: 'editAdapter' });
383 | }}> Edit Wildcard Table
384 |
385 | setHidden(!hidden)}
386 | codeEditorHidden={codeEditorHidden}>
387 | { hidden ? "↑ Open Wildcard Table" : "↓ Close Wildcard Table" }
388 |
389 | >
390 | )}
391 |
392 |
393 | Wildcard v0.2
394 |
404 |
405 |
413 |
414 | {saveAdapterCode();}}> Save
416 |
417 | setCodeEditorHidden(true)}> Close
419 |
420 | >;
421 | }
422 |
423 | export default WcPanel;
424 |
--------------------------------------------------------------------------------
/src/ui/cell_editors/formulaEditor.js:
--------------------------------------------------------------------------------
1 | import Handsontable from 'handsontable';
2 |
3 | class FormulaEditor extends Handsontable.editors.TextEditor {
4 | constructor(hotInstance) {
5 | super(hotInstance);
6 | }
7 |
8 | prepare(row, col, prop, td, originalValue, cellProperties) {
9 | super.prepare(row, col, prop, td, originalValue, cellProperties);
10 | if(cellProperties.formula) {
11 | this.formula = cellProperties.formula
12 | } else {
13 | this.formula = null
14 | }
15 | }
16 |
17 | // If cell contains a formula, edit the formula, not the value
18 | beginEditing(newValue, event) {
19 | let valueToEdit = newValue
20 |
21 | if (this.formula) {
22 | valueToEdit = this.formula
23 | }
24 |
25 | super.beginEditing(valueToEdit, event)
26 | }
27 | }
28 |
29 | export { FormulaEditor };
30 |
--------------------------------------------------------------------------------
/src/ui/cell_editors/fullCalendarEditor.css:
--------------------------------------------------------------------------------
1 | #open-apps-calendar-container {
2 | background-color: white;
3 | padding: 20px;
4 | position: fixed;
5 | height: 500px;
6 | width: 700px;
7 | bottom: 100px;
8 | right: 50px;
9 | z-index: 1000;
10 | }
--------------------------------------------------------------------------------
/src/ui/cell_editors/fullCalendarEditor.js:
--------------------------------------------------------------------------------
1 | import moment from 'moment';
2 | import Handsontable from 'handsontable';
3 | import { Calendar } from '@fullcalendar/core';
4 | import googleCalendarPlugin from '@fullcalendar/google-calendar'
5 | import dayGridPlugin from '@fullcalendar/daygrid';
6 | import interactionPlugin from '@fullcalendar/interaction';
7 |
8 | import '@fullcalendar/core/main.css';
9 | import '@fullcalendar/daygrid/main.css';
10 | import '@fullcalendar/timegrid/main.css';
11 | import '@fullcalendar/list/main.css';
12 | import './fullCalendarEditor.css'
13 |
14 | // convert HTML to a dom element
15 | function htmlToElement(html) {
16 | var template = document.createElement('template');
17 | html = html.trim(); // Never return a text node of whitespace as the result
18 | template.innerHTML = html;
19 | return template.content.firstChild;
20 | }
21 |
22 | class FullCalendarEditor extends Handsontable.editors.BaseEditor {
23 | constructor(hotInstance) {
24 | super(hotInstance);
25 | }
26 |
27 | init () {
28 | this.selectedDate = new Date()
29 |
30 | this.calendarDiv = htmlToElement(``);
31 | document.body.appendChild(this.calendarDiv);
32 |
33 | this.calendar = new Calendar(document.getElementById('open-apps-calendar'), {
34 | plugins: [ interactionPlugin, dayGridPlugin, googleCalendarPlugin ],
35 | selectable: true,
36 | select: (info) => {
37 | console.log("selected ", info.start, info.end);
38 | this.selectedDate = info.start;
39 | },
40 | googleCalendarApiKey: 'AIzaSyCpKAQzhc5HOvQ1a7j1QXEKqpIAeEaawLE',
41 | events: {
42 | googleCalendarId: '858lgk6ojl7vio2e3d15gkppv4@group.calendar.google.com'
43 | }
44 | });
45 |
46 | this.calendar.render();
47 | this.calendarDiv.style.display = "none"
48 |
49 | this.calendarDiv.addEventListener('mousedown', e => {
50 | event.stopPropagation()
51 | });
52 | }
53 |
54 | getValue() {
55 | return moment(this.selectedDate).format("M/D/YYYY");
56 | }
57 |
58 | setValue(newValue) {
59 | let date = moment(newValue, "M/D/YYYY").toDate;
60 | this.calendar.select(date);
61 | }
62 |
63 | open() {
64 | this.calendarDiv.style.display = '';
65 | }
66 |
67 | close() {
68 | this.calendarDiv.style.display = 'none';
69 | }
70 |
71 | focus() {
72 | this.calendarDiv.focus();
73 | }
74 | }
75 |
76 | export { FullCalendarEditor };
77 |
--------------------------------------------------------------------------------
/src/ui/cell_editors/richTextEditor.css:
--------------------------------------------------------------------------------
1 | #open-apps-rich-text-editor-container {
2 | background-color: white;
3 | padding: 20px;
4 | position: fixed;
5 | height: 500px;
6 | width: 700px;
7 | bottom: 100px;
8 | right: 50px;
9 | z-index: 1000;
10 | }
11 |
12 | #wildcard-container strong, .ck strong {
13 | font-weight: bold;
14 | }
15 |
16 | #wildcard-container a:-webkit-any-link, .ck a:-webkit-any-link {
17 | color: -webkit-link;
18 | cursor: pointer;
19 | text-decoration: underline;
20 | }
21 |
22 | #wildcard-container ul, .ck ul {
23 | display: block;
24 | list-style-type: disc;
25 | margin-block-start: 1em;
26 | margin-block-end: 1em;
27 | margin-inline-start: 0px;
28 | margin-inline-end: 0px;
29 | padding-inline-start: 40px;
30 | }
31 |
32 | #wildcard-container ol, .ck ol {
33 | display: block;
34 | list-style-type: decimal;
35 | margin-block-start: 1em;
36 | margin-block-end: 1em;
37 | margin-inline-start: 0px;
38 | margin-inline-end: 0px;
39 | padding-inline-start: 40px;
40 | }
--------------------------------------------------------------------------------
/src/ui/overrides.css:
--------------------------------------------------------------------------------
1 | /* In WcPanel.tsx, we set the z-index of our table to 2200 (to put it above original site content)
2 | So, we also need to tell the context menu to be visible above the table.
3 | We do this by overridding the default HoT CSS. */
4 | .htContextMenu:not(.htGhostTable) {
5 | z-index: 2300;
6 | }
7 |
8 | th span.formula-header:before {
9 | content: "f(x)";
10 | margin-right: 10px;
11 | margin-left: -10px;
12 | font-style: italic;
13 | font-size: 12px;
14 | color: #888;
15 | }
16 |
17 | th span.element-header:before {
18 | content: ">";
19 | margin-right: 10px;
20 | margin-left: -10px;
21 | font-style: italic;
22 | font-size: 12px;
23 | color: #888;
24 | }
25 |
--------------------------------------------------------------------------------
/src/utils.ts:
--------------------------------------------------------------------------------
1 | /** Try to extract the first integer from a text string or HTML element */
2 | export function extractNumber(input: any, defaultValue?: number, commaIsDecimalSeparator: boolean = false): number {
3 | let text, result;
4 |
5 | if (input instanceof HTMLElement) {
6 | text = input.textContent
7 | } else if (typeof input === "string") {
8 | text = input
9 | }
10 | if (text) { result = text.match(/[^0-9]*([0-9\.\,]*).*/)[1] }
11 | if (result && !commaIsDecimalSeparator) {
12 | // in the US and elsewhere, commas are thousands separator so they can be removed.
13 | // in other countries, the commas is the decimal separator.
14 | result = result.replace(/,/g, '')
15 | }
16 | if (result) {
17 | return Number(result)
18 | } else if (defaultValue !== undefined) {
19 | return defaultValue
20 | } else {
21 | return null
22 | }
23 | }
24 |
25 | /** Returns true if current page URL contains given URL as substring.
26 | * Mainly used in [[SiteAdapterOptions]] enable functions
27 | */
28 | export function urlContains(fragment: string): boolean {
29 | return String(window.location).indexOf(fragment) !== -1
30 | }
31 |
32 | export function urlExact(url: string): boolean {
33 | return String(window.location) === url ||
34 | String(window.location) === "https://" + url
35 | }
36 |
37 | export function urlMatches(regex: RegExp): boolean {
38 | return regex.test(String(window.location));
39 | }
40 |
41 | export function htmlToElement(html): HTMLElement {
42 | var template = document.createElement('template');
43 | html = html.trim(); // Never return a text node of whitespace as the result
44 | template.innerHTML = html;
45 | return template.content.firstChild as HTMLElement;
46 | }
47 |
48 | export function onDomReady(fn) {
49 | if (document.readyState != 'loading') fn();
50 | else document.addEventListener('DOMContentLoaded', fn)
51 | }
52 |
53 | export function readFromChromeLocalStorage(keys) {
54 | return new Promise((resolve, reject) => {
55 | chrome.storage.local.get(keys, (results) => {
56 | resolve(results)
57 | });
58 | });
59 | }
60 |
61 | export function saveToChromeLocalStorage(entries) {
62 | return new Promise((resolve, reject) => {
63 | chrome.storage.local.set(entries);
64 | resolve();
65 | });
66 | }
67 |
68 | export function removeFromChromeLocalStorage(keys) {
69 | return new Promise((resolve, reject) => {
70 | chrome.storage.local.remove(keys);
71 | resolve();
72 | });
73 | }
74 |
75 | export function compileJavascript(code) {
76 | return eval(code);
77 | }
78 |
79 | export function throttleFunction(delay, fn) {
80 | let lastCall = 0;
81 | return function (...args) {
82 | const now = (new Date).getTime();
83 | if (now - lastCall < delay) {
84 | return;
85 | }
86 | lastCall = now;
87 | return fn(...args);
88 | }
89 | }
--------------------------------------------------------------------------------
/src/wildcard-ajax.js:
--------------------------------------------------------------------------------
1 | function onError(error) {
2 | console.error(`Error: ${error}`);
3 | }
4 |
5 | function listener(details) {
6 | if(navigator.userAgent.indexOf("Firefox") != -1 )
7 | {
8 | let filter = browser.webRequest.filterResponseData(details.requestId);
9 |
10 | let data = [];
11 | filter.ondata = event =>
12 | {
13 | data.push(event.data);
14 | filter.write(event.data);
15 | };
16 |
17 | filter.onstop = async event =>
18 | {
19 | let blob = new Blob(data, {type: 'application/json'});
20 | let bstr = await blob.text();
21 | let obj = undefined;
22 | try
23 | {
24 | obj = JSON.parse(bstr);
25 | } catch
26 | {
27 |
28 | }
29 | if (obj !== undefined)
30 | {
31 | browser.tabs.sendMessage(
32 | details.tabId,
33 | {
34 | url: details.url,
35 | data: obj
36 | }
37 | ).catch(
38 | onError
39 | )
40 | }
41 | filter.close();
42 | };
43 | }
44 | }
45 |
46 | if(navigator.userAgent.indexOf("Firefox") != -1 )
47 | {
48 | browser.webRequest.onBeforeRequest.addListener(
49 | listener,
50 | {urls: [""]},
51 | ["blocking"]
52 | );
53 | }
--------------------------------------------------------------------------------
/src/wildcard-background.ts:
--------------------------------------------------------------------------------
1 | // This is an extension "background script" which access certain Chrome APIs
2 | // and makes them available to content scripts.
3 | // For more info: https://stackoverflow.com/questions/14211771/accessing-chrome-history-from-javascript-in-an-extension-and-changing-the-page-b
4 |
5 | 'use strict';
6 |
7 | window['state'] = {};
8 |
9 | // to add functionality only available in background scripts,
10 | // add a message handler to this list
11 |
12 | let fetchWithTimeout: any = (url, options, timeout) => {
13 | return new Promise((resolve, reject) => {
14 | fetch(url, options).then(resolve, reject);
15 |
16 | if (timeout) {
17 | const e = new Error("Connection timed out");
18 | setTimeout(reject, timeout, e);
19 | }
20 | });
21 | }
22 |
23 | const getVisits = (request, sender, sendResponse) => {
24 | chrome.history.getVisits({ url: request.url }, (visits) => {
25 | sendResponse({ visits: visits });
26 | })
27 | }
28 |
29 | const getReadingTime = (request, sender, sendResponse) => {
30 | const apiUrl = `https://klopets.com/readtime/?url=${request.url}&json`
31 | fetchWithTimeout(apiUrl, {}, 5000)
32 | .then(r => r.json())
33 | .catch(err => sendResponse({ error: "couldn't fetch read time" }))
34 | .then(result => {
35 | console.log("result", result)
36 | if (result.seconds) {
37 | sendResponse({ seconds: result.seconds })
38 | } else {
39 | sendResponse({ error: "couldn't fetch read time" })
40 | }
41 | })
42 | }
43 |
44 | const forwardToContentScripts = (request, sender, sendResponse) => {
45 | chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
46 | if (tabs && tabs.length) {
47 | chrome.tabs.sendMessage(tabs[0].id, request);
48 | }
49 | });
50 | }
51 |
52 | const handlers = {
53 | getVisits: getVisits,
54 | getReadingTime: getReadingTime,
55 | deleteAdapter: forwardToContentScripts,
56 | saveAdapter: forwardToContentScripts,
57 | resetAdapter: forwardToContentScripts,
58 | editAdapter: forwardToContentScripts
59 | }
60 |
61 | chrome.runtime.onMessage.addListener(
62 | function (request, sender, sendResponse) {
63 | let handler = handlers[request.command]
64 |
65 | if (handler) {
66 | handler.call(this, request, sender, sendResponse)
67 | }
68 |
69 | return true;
70 | });
71 |
72 | chrome.contextMenus.create({
73 | title: "Wildcard",
74 | id: "wildcard",
75 | type: "normal",
76 | contexts: ["page"]
77 | }, () => {
78 | chrome.contextMenus.create({
79 | title: "Create Adapter",
80 | contexts: ["page"],
81 | parentId: "wildcard",
82 | onclick: function () {
83 | chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
84 | if (tabs && tabs.length) {
85 | // send message to active tab
86 | chrome.tabs.sendMessage(tabs[0].id, { command: "createAdapter" }, (response) => {
87 | if (response.error) {
88 | alert(response.error);
89 | }
90 | });
91 | }
92 | });
93 | }
94 | });
95 | // chrome.contextMenus.create({
96 | // title: "Edit Adapter",
97 | // contexts: ["page"],
98 | // parentId: "wildcard",
99 | // onclick: function () {
100 | // chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
101 | // if (tabs && tabs.length) {
102 | // // send message to active tab
103 | // chrome.tabs.sendMessage(tabs[0].id, { command: "openCodeEditor" }, (response) => {
104 | // if (response.error) {
105 | // alert(response.error);
106 | // }
107 | // });
108 | // }
109 | // });
110 | // }
111 | // });
112 | // chrome.contextMenus.create({
113 | // title: "Open Options Page",
114 | // contexts: ["page"],
115 | // parentId: "wildcard",
116 | // onclick: function () {
117 | // chrome.runtime.openOptionsPage();
118 | // }
119 | // });
120 | });
--------------------------------------------------------------------------------
/src/wildcard.tsx:
--------------------------------------------------------------------------------
1 | /** This is the output file that the browser runs on each page.
2 | * It compiles the framework and all the site adapters into one file.
3 | */
4 |
5 | 'use strict';
6 |
7 | import React from "react";
8 | import { render } from "react-dom";
9 | import { createStore, applyMiddleware, bindActionCreators } from "redux";
10 | import { Provider, connect } from 'react-redux'
11 | import { composeWithDevTools } from 'redux-devtools-extension';
12 | import reducer from './core/reducer';
13 | import { debugMiddleware } from './core/debug'
14 | import { htmlToElement } from './utils'
15 | import WcPanel from "./ui/WcPanel";
16 | import { getActiveAdapter } from "./site_adapters"
17 | import { userStore as userTableAdapter } from "./localStorageAdapter"
18 | import thunk from 'redux-thunk';
19 | import { initializeActions } from './core/actions'
20 | import { getFinalRecords, getFinalAttributes } from './core/getFinalTable'
21 | import { TableAdapterMiddleware } from './tableAdapterMiddleware'
22 | import { startScrapingListener, stopScrapingListener, resetScrapingListener, editScraper } from './end_user_scraper';
23 | import { setCachedActiveAdapter } from "./end_user_scraper/state";
24 |
25 | // todo: move this out of this file
26 | const connectRedux = (component, actions) => {
27 | const mapStateToProps = state => ({
28 | // todo: when we have non-app records and attributes,
29 | // merge them in the redux state, and pass in merged data here --
30 | // this panel view isn't responsible for combining them.
31 | // keep this component thin.
32 | records: getFinalRecords(state),
33 | attributes: getFinalAttributes(state),
34 | query: state.query
35 | })
36 |
37 | const mapDispatchToProps = dispatch => ({
38 | actions: bindActionCreators(actions, dispatch)
39 | })
40 |
41 | return connect(
42 | mapStateToProps,
43 | mapDispatchToProps
44 | )(component)
45 | }
46 |
47 | export const run = async function () {
48 | //console.log("Re-running adapter")
49 | const wcRoot = document.getElementById('wc--root');
50 | if (wcRoot) {
51 | wcRoot.remove();
52 | }
53 | const activeSiteAdapter = await getActiveAdapter();
54 | if (!activeSiteAdapter) { return; }
55 |
56 | activeSiteAdapter.initialize();
57 |
58 | //userTableAdapter.initialize(activeSiteAdapter.name)
59 |
60 | const tables = { app: activeSiteAdapter }
61 |
62 | // pass our TableAdapter objects into action creators,
63 | // so action creator functions can access them.
64 | const actions = initializeActions(tables)
65 |
66 | // stash active adapter if we are in creation mode
67 | setCachedActiveAdapter(activeSiteAdapter);
68 |
69 | // Add extra space to the bottom of the page for the wildcard panel
70 | // todo: move this elsewhere?
71 | document.querySelector("body").style["margin-bottom"] = "300px";
72 |
73 | // Create our redux store
74 | const store = createStore(reducer, composeWithDevTools(
75 | applyMiddleware(thunk),
76 | applyMiddleware(TableAdapterMiddleware(activeSiteAdapter)),
77 | // applyMiddleware(TableAdapterMiddleware(userTableAdapter)),
78 | applyMiddleware(debugMiddleware),
79 | ));
80 |
81 | // Subscribe to app data updates from the site adapter and user store
82 | activeSiteAdapter.subscribe(table =>
83 | store.dispatch(actions.tableReloaded(table))
84 | )
85 |
86 | // userTableAdapter.subscribe(table =>
87 | // store.dispatch(actions.tableReloaded(table))
88 | // )
89 |
90 | // todo: wrap storage stuff in a module
91 |
92 | // Load saved query (including sorting)
93 | chrome.storage.local.get(`query:${activeSiteAdapter.name}`, (result) => {
94 | const query = result[`query:${activeSiteAdapter.name}`]
95 | if (query) {
96 | store.dispatch(actions.sortRecords(query.sortConfig))
97 | } else {
98 | console.log("no query")
99 | }
100 | })
101 |
102 | // save the query in local storage when it updates
103 | store.subscribe(() => {
104 | const state = store.getState();
105 | const queryToStore = { [`query:${activeSiteAdapter.name}`]: state.query }
106 | chrome.storage.local.set(queryToStore)
107 | })
108 |
109 | // Initialize the container for our view
110 | document.body.appendChild(
111 | htmlToElement(`
`) as HTMLElement);
112 |
113 | // in the future, rather than hardcode WcPanel here,
114 | // could dynamically choose a table editor instrument
115 | const TableEditor = connectRedux(WcPanel, actions)
116 |
117 | render(
118 |
119 |
120 | ,
121 | document.getElementById("wc--root")
122 | );
123 |
124 | }
125 |
126 | chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
127 | switch(request.command) {
128 | case 'createAdapter':
129 | startScrapingListener();
130 | break;
131 | case 'saveAdapter':
132 | stopScrapingListener({ save: true });
133 | break;
134 | case 'deleteAdapter':
135 | stopScrapingListener({ save: false });
136 | break;
137 | case 'resetAdapter':
138 | resetScrapingListener();
139 | break;
140 | case 'editAdapter':
141 | editScraper();
142 | break;
143 | default:
144 | break;
145 | }
146 | });
147 |
148 | run();
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "typeRoots": ["node_modules/@types"],
4 | "allowSyntheticDefaultImports": true,
5 | "jsx": "react",
6 | "downlevelIteration": true
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/vendor/ace/theme-monokai.js:
--------------------------------------------------------------------------------
1 | define("ace/theme/monokai",["require","exports","module","ace/lib/dom"],function(e,t,n){t.isDark=!0,t.cssClass="ace-monokai",t.cssText=".ace-monokai .ace_gutter {background: #2F3129;color: #8F908A}.ace-monokai .ace_print-margin {width: 1px;background: #555651}.ace-monokai {background-color: #272822;color: #F8F8F2}.ace-monokai .ace_cursor {color: #F8F8F0}.ace-monokai .ace_marker-layer .ace_selection {background: #49483E}.ace-monokai.ace_multiselect .ace_selection.ace_start {box-shadow: 0 0 3px 0px #272822;}.ace-monokai .ace_marker-layer .ace_step {background: rgb(102, 82, 0)}.ace-monokai .ace_marker-layer .ace_bracket {margin: -1px 0 0 -1px;border: 1px solid #49483E}.ace-monokai .ace_marker-layer .ace_active-line {background: #202020}.ace-monokai .ace_gutter-active-line {background-color: #272727}.ace-monokai .ace_marker-layer .ace_selected-word {border: 1px solid #49483E}.ace-monokai .ace_invisible {color: #52524d}.ace-monokai .ace_entity.ace_name.ace_tag,.ace-monokai .ace_keyword,.ace-monokai .ace_meta.ace_tag,.ace-monokai .ace_storage {color: #F92672}.ace-monokai .ace_punctuation,.ace-monokai .ace_punctuation.ace_tag {color: #fff}.ace-monokai .ace_constant.ace_character,.ace-monokai .ace_constant.ace_language,.ace-monokai .ace_constant.ace_numeric,.ace-monokai .ace_constant.ace_other {color: #AE81FF}.ace-monokai .ace_invalid {color: #F8F8F0;background-color: #F92672}.ace-monokai .ace_invalid.ace_deprecated {color: #F8F8F0;background-color: #AE81FF}.ace-monokai .ace_support.ace_constant,.ace-monokai .ace_support.ace_function {color: #66D9EF}.ace-monokai .ace_fold {background-color: #A6E22E;border-color: #F8F8F2}.ace-monokai .ace_storage.ace_type,.ace-monokai .ace_support.ace_class,.ace-monokai .ace_support.ace_type {font-style: italic;color: #66D9EF}.ace-monokai .ace_entity.ace_name.ace_function,.ace-monokai .ace_entity.ace_other,.ace-monokai .ace_entity.ace_other.ace_attribute-name,.ace-monokai .ace_variable {color: #A6E22E}.ace-monokai .ace_variable.ace_parameter {font-style: italic;color: #FD971F}.ace-monokai .ace_string {color: #E6DB74}.ace-monokai .ace_comment {color: #75715E}.ace-monokai .ace_indent-guide {background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAACCAYAAACZgbYnAAAAEklEQVQImWPQ0FD0ZXBzd/wPAAjVAoxeSgNeAAAAAElFTkSuQmCC) right repeat-y}";var r=e("../lib/dom");r.importCssString(t.cssText,t.cssClass)});
2 | (function() {
3 | window.require(["ace/theme/monokai"], function(m) {
4 | if (typeof module == "object" && typeof exports == "object" && module) {
5 | module.exports = m;
6 | }
7 | });
8 | })();
9 |
--------------------------------------------------------------------------------