par-scrape 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- par_scrape-0.1.0/.gitignore +74 -0
- par_scrape-0.1.0/LICENSE +21 -0
- par_scrape-0.1.0/PKG-INFO +185 -0
- par_scrape-0.1.0/README.md +120 -0
- par_scrape-0.1.0/pyproject.toml +101 -0
- par_scrape-0.1.0/src/par_scrape/__init__.py +29 -0
- par_scrape-0.1.0/src/par_scrape/__main__.py +306 -0
- par_scrape-0.1.0/src/par_scrape/fetch_html.py +197 -0
- par_scrape-0.1.0/src/par_scrape/pricing.py +86 -0
- par_scrape-0.1.0/src/par_scrape/py.typed +0 -0
- par_scrape-0.1.0/src/par_scrape/scrape_data.py +230 -0
- par_scrape-0.1.0/src/par_scrape/utils.py +6 -0
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
### PythonVanilla template
|
|
2
|
+
# Byte-compiled / optimized / DLL files
|
|
3
|
+
__pycache__/
|
|
4
|
+
*.py[cod]
|
|
5
|
+
*$py.class
|
|
6
|
+
|
|
7
|
+
# C extensions
|
|
8
|
+
*.so
|
|
9
|
+
|
|
10
|
+
# Distribution / packaging
|
|
11
|
+
.Python
|
|
12
|
+
build/
|
|
13
|
+
develop-eggs/
|
|
14
|
+
dist/
|
|
15
|
+
downloads/
|
|
16
|
+
eggs/
|
|
17
|
+
.eggs/
|
|
18
|
+
lib/
|
|
19
|
+
lib64/
|
|
20
|
+
parts/
|
|
21
|
+
sdist/
|
|
22
|
+
var/
|
|
23
|
+
wheels/
|
|
24
|
+
share/python-wheels/
|
|
25
|
+
*.egg-info/
|
|
26
|
+
.installed.cfg
|
|
27
|
+
*.egg
|
|
28
|
+
MANIFEST
|
|
29
|
+
|
|
30
|
+
# Installer logs
|
|
31
|
+
pip-log.txt
|
|
32
|
+
pip-delete-this-directory.txt
|
|
33
|
+
|
|
34
|
+
# Unit test / coverage reports
|
|
35
|
+
htmlcov/
|
|
36
|
+
.tox/
|
|
37
|
+
.nox/
|
|
38
|
+
.coverage
|
|
39
|
+
.coverage.*
|
|
40
|
+
.cache
|
|
41
|
+
nosetests.xml
|
|
42
|
+
coverage.xml
|
|
43
|
+
*.cover
|
|
44
|
+
*.py,cover
|
|
45
|
+
.hypothesis/
|
|
46
|
+
.pytest_cache/
|
|
47
|
+
cover/
|
|
48
|
+
|
|
49
|
+
# Translations
|
|
50
|
+
*.mo
|
|
51
|
+
*.pot
|
|
52
|
+
|
|
53
|
+
# pyenv
|
|
54
|
+
# For a library or package, you might want to ignore these files since the code is
|
|
55
|
+
# intended to run in multiple environments; otherwise, check them in:
|
|
56
|
+
# .python-version
|
|
57
|
+
|
|
58
|
+
# pipenv
|
|
59
|
+
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
|
60
|
+
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
|
61
|
+
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
|
62
|
+
# install all needed dependencies.
|
|
63
|
+
#Pipfile.lock
|
|
64
|
+
|
|
65
|
+
# PEP 582; used by e.g. github.com/David-OConnor/pyflow
|
|
66
|
+
__pypackages__/
|
|
67
|
+
|
|
68
|
+
.aider*
|
|
69
|
+
**/venv
|
|
70
|
+
**/.venv
|
|
71
|
+
**/.env
|
|
72
|
+
**/.idea
|
|
73
|
+
/config.json
|
|
74
|
+
/output/
|
par_scrape-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Paul Robello
|
|
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.
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: par_scrape
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A versatile web scraping tool with options for Selenium or Playwright, featuring OpenAI-powered data extraction and formatting.
|
|
5
|
+
Project-URL: Homepage, https://github.com/paulrobello/par_scrape
|
|
6
|
+
Project-URL: Documentation, https://github.com/paulrobello/par_scrape/blob/main/README.md
|
|
7
|
+
Project-URL: Repository, https://github.com/paulrobello/par_scrape
|
|
8
|
+
Project-URL: Issues, https://github.com/paulrobello/par_scrape/issues
|
|
9
|
+
Project-URL: Discussions, https://github.com/paulrobello/par_scrape/discussions
|
|
10
|
+
Project-URL: Wiki, https://github.com/paulrobello/par_scrape/wiki
|
|
11
|
+
Author-email: Paul Robello <probello@gmail.com>
|
|
12
|
+
Maintainer-email: Paul Robello <probello@gmail.com>
|
|
13
|
+
License: MIT License
|
|
14
|
+
|
|
15
|
+
Copyright (c) 2024 Paul Robello
|
|
16
|
+
|
|
17
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
18
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
19
|
+
in the Software without restriction, including without limitation the rights
|
|
20
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
21
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
22
|
+
furnished to do so, subject to the following conditions:
|
|
23
|
+
|
|
24
|
+
The above copyright notice and this permission notice shall be included in all
|
|
25
|
+
copies or substantial portions of the Software.
|
|
26
|
+
|
|
27
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
28
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
29
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
30
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
31
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
32
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
33
|
+
SOFTWARE.
|
|
34
|
+
License-File: LICENSE
|
|
35
|
+
Keywords: data extraction,openai,playwright,selenium,web scraping
|
|
36
|
+
Classifier: Development Status :: 4 - Beta
|
|
37
|
+
Classifier: Environment :: Console
|
|
38
|
+
Classifier: Intended Audience :: Developers
|
|
39
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
40
|
+
Classifier: Programming Language :: Python :: 3
|
|
41
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
42
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
43
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: Browsers
|
|
44
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
45
|
+
Classifier: Topic :: Text Processing :: Markup :: HTML
|
|
46
|
+
Classifier: Typing :: Typed
|
|
47
|
+
Requires-Python: >=3.11
|
|
48
|
+
Requires-Dist: aiofiles>=24.1.0
|
|
49
|
+
Requires-Dist: beautifulsoup4>=4.12.3
|
|
50
|
+
Requires-Dist: html2text>=2024.2.26
|
|
51
|
+
Requires-Dist: openai>=1.43.0
|
|
52
|
+
Requires-Dist: openpyxl>=3.1.5
|
|
53
|
+
Requires-Dist: pandas>=2.2.2
|
|
54
|
+
Requires-Dist: playwright>=1.46.0
|
|
55
|
+
Requires-Dist: pydantic>=2.9.0
|
|
56
|
+
Requires-Dist: python-dotenv>=1.0.1
|
|
57
|
+
Requires-Dist: rich>=13.8.0
|
|
58
|
+
Requires-Dist: selenium>=4.24.0
|
|
59
|
+
Requires-Dist: tabulate>=0.9.0
|
|
60
|
+
Requires-Dist: tiktoken>=0.7.0
|
|
61
|
+
Requires-Dist: typer>=0.12.5
|
|
62
|
+
Requires-Dist: typing-extensions>=4.12.2
|
|
63
|
+
Requires-Dist: webdriver-manager>=4.0.2
|
|
64
|
+
Description-Content-Type: text/markdown
|
|
65
|
+
|
|
66
|
+
# PAR Scrape
|
|
67
|
+
|
|
68
|
+
[](https://pypi.org/project/par_scrape/)
|
|
69
|
+
[](https://pypi.org/project/par_scrape/)
|
|
70
|
+

|
|
71
|
+

|
|
72
|
+

|
|
73
|
+
|
|
74
|
+
## About
|
|
75
|
+
PAR Scrape is a versatile web scraping tool with options for Selenium or Playwright, featuring OpenAI-powered data extraction and formatting.
|
|
76
|
+
|
|
77
|
+
[](https://buymeacoffee.com/probello3)
|
|
78
|
+
|
|
79
|
+
## Screenshots
|
|
80
|
+

|
|
81
|
+
|
|
82
|
+
## Features
|
|
83
|
+
|
|
84
|
+
- Web scraping using Selenium or Playwright
|
|
85
|
+
- OpenAI-powered data extraction and formatting
|
|
86
|
+
- Supports multiple output formats (JSON, Excel, CSV, Markdown)
|
|
87
|
+
- Customizable field extraction
|
|
88
|
+
- Token usage and cost estimation
|
|
89
|
+
|
|
90
|
+
## Installation
|
|
91
|
+
|
|
92
|
+
To install PAR Scrape, make sure you have Python 3.11 or higher and [uv](https://pypi.org/project/uv/) installed.
|
|
93
|
+
|
|
94
|
+
### Installation From Source
|
|
95
|
+
|
|
96
|
+
Then, follow these steps:
|
|
97
|
+
|
|
98
|
+
1. Clone the repository:
|
|
99
|
+
```bash
|
|
100
|
+
git clone https://github.com/paulrobello/par_scrape.git
|
|
101
|
+
cd par_scrape
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
2. Install the package dependencies using uv:
|
|
105
|
+
```bash
|
|
106
|
+
uv sync
|
|
107
|
+
```
|
|
108
|
+
### Installation From PyPI
|
|
109
|
+
|
|
110
|
+
To install PAR Scrape from PyPI, run any of the following commands:
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
uv tool install par_scrape
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
pipx install par_scrape
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
## Usage
|
|
121
|
+
|
|
122
|
+
To use PAR Scrape, you can run it from the command line with various options. Here's a basic example:
|
|
123
|
+
|
|
124
|
+
### Running from source
|
|
125
|
+
```bash
|
|
126
|
+
uv run par_scrape --url "https://openai.com/api/pricing/" --fields "Model" --fields "Pricing Input" --fields "Pricing Output" --scraper selenium --model gpt-4o-mini --display-output md
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
### Running if installed from PyPI
|
|
130
|
+
```bash
|
|
131
|
+
par_scrape --url "https://openai.com/api/pricing/" --fields "Title" "Number of Points" "Creator" "Time Posted" "Number of Comments" --scraper selenium --model gpt-4o-mini --display-output md
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
### Options
|
|
135
|
+
|
|
136
|
+
- `--url`: The URL to scrape (default: "https://openai.com/api/pricing/")
|
|
137
|
+
- `--fields`: Fields to extract from the webpage (default: ["Model", "Pricing Input", "Pricing Output"])
|
|
138
|
+
- `--scraper`: Scraper to use: 'selenium' or 'playwright' (default: "selenium")
|
|
139
|
+
- `--remove-output`: Remove output folder before running
|
|
140
|
+
- `--headless`: Run in headless mode (for Selenium) (default: True)
|
|
141
|
+
- `--model`: OpenAI model to use for processing (default: "gpt-4o-mini")
|
|
142
|
+
- `--display-output`: Display output in terminal (md, csv, or json)
|
|
143
|
+
- `--output-folder`: Specify the location of the output folder (default: "./output")
|
|
144
|
+
- `--silent`: Run in silent mode, suppressing output
|
|
145
|
+
- `--run-name`: Specify a name for this run
|
|
146
|
+
- `--cleanup`: Remove output folder before exiting
|
|
147
|
+
|
|
148
|
+
### Examples
|
|
149
|
+
|
|
150
|
+
1. Basic usage with default options:
|
|
151
|
+
```bash
|
|
152
|
+
par_scrape --url "https://openai.com/api/pricing/" --fields "Model" "Pricing Input" "Pricing Output"
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
2. Using Playwright and displaying JSON output:
|
|
156
|
+
```bash
|
|
157
|
+
par_scrape --url "https://example.com" --fields "Title" "Description" "Price" --scraper playwright --display-output json
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
3. Specifying a custom model and output folder:
|
|
161
|
+
```bash
|
|
162
|
+
par_scrape --url "https://example.com" --fields "Title" "Description" "Price" --model gpt-4 --output-folder ./custom_output
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
4. Running in silent mode with a custom run name:
|
|
166
|
+
```bash
|
|
167
|
+
par_scrape --url "https://example.com" --fields "Title" "Description" "Price" --silent --run-name my_custom_run
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
5. Using the cleanup option to remove the output folder after scraping:
|
|
171
|
+
```bash
|
|
172
|
+
par_scrape --url "https://example.com" --fields "Title" "Description" "Price" --cleanup
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
## Contributing
|
|
176
|
+
|
|
177
|
+
Contributions are welcome! Please feel free to submit a Pull Request.
|
|
178
|
+
|
|
179
|
+
## License
|
|
180
|
+
|
|
181
|
+
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
|
182
|
+
|
|
183
|
+
## Author
|
|
184
|
+
|
|
185
|
+
Paul Robello - probello@gmail.com
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# PAR Scrape
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/par_scrape/)
|
|
4
|
+
[](https://pypi.org/project/par_scrape/)
|
|
5
|
+

|
|
6
|
+

|
|
7
|
+

|
|
8
|
+
|
|
9
|
+
## About
|
|
10
|
+
PAR Scrape is a versatile web scraping tool with options for Selenium or Playwright, featuring OpenAI-powered data extraction and formatting.
|
|
11
|
+
|
|
12
|
+
[](https://buymeacoffee.com/probello3)
|
|
13
|
+
|
|
14
|
+
## Screenshots
|
|
15
|
+

|
|
16
|
+
|
|
17
|
+
## Features
|
|
18
|
+
|
|
19
|
+
- Web scraping using Selenium or Playwright
|
|
20
|
+
- OpenAI-powered data extraction and formatting
|
|
21
|
+
- Supports multiple output formats (JSON, Excel, CSV, Markdown)
|
|
22
|
+
- Customizable field extraction
|
|
23
|
+
- Token usage and cost estimation
|
|
24
|
+
|
|
25
|
+
## Installation
|
|
26
|
+
|
|
27
|
+
To install PAR Scrape, make sure you have Python 3.11 or higher and [uv](https://pypi.org/project/uv/) installed.
|
|
28
|
+
|
|
29
|
+
### Installation From Source
|
|
30
|
+
|
|
31
|
+
Then, follow these steps:
|
|
32
|
+
|
|
33
|
+
1. Clone the repository:
|
|
34
|
+
```bash
|
|
35
|
+
git clone https://github.com/paulrobello/par_scrape.git
|
|
36
|
+
cd par_scrape
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
2. Install the package dependencies using uv:
|
|
40
|
+
```bash
|
|
41
|
+
uv sync
|
|
42
|
+
```
|
|
43
|
+
### Installation From PyPI
|
|
44
|
+
|
|
45
|
+
To install PAR Scrape from PyPI, run any of the following commands:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
uv tool install par_scrape
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
pipx install par_scrape
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Usage
|
|
56
|
+
|
|
57
|
+
To use PAR Scrape, you can run it from the command line with various options. Here's a basic example:
|
|
58
|
+
|
|
59
|
+
### Running from source
|
|
60
|
+
```bash
|
|
61
|
+
uv run par_scrape --url "https://openai.com/api/pricing/" --fields "Model" --fields "Pricing Input" --fields "Pricing Output" --scraper selenium --model gpt-4o-mini --display-output md
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### Running if installed from PyPI
|
|
65
|
+
```bash
|
|
66
|
+
par_scrape --url "https://openai.com/api/pricing/" --fields "Title" "Number of Points" "Creator" "Time Posted" "Number of Comments" --scraper selenium --model gpt-4o-mini --display-output md
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### Options
|
|
70
|
+
|
|
71
|
+
- `--url`: The URL to scrape (default: "https://openai.com/api/pricing/")
|
|
72
|
+
- `--fields`: Fields to extract from the webpage (default: ["Model", "Pricing Input", "Pricing Output"])
|
|
73
|
+
- `--scraper`: Scraper to use: 'selenium' or 'playwright' (default: "selenium")
|
|
74
|
+
- `--remove-output`: Remove output folder before running
|
|
75
|
+
- `--headless`: Run in headless mode (for Selenium) (default: True)
|
|
76
|
+
- `--model`: OpenAI model to use for processing (default: "gpt-4o-mini")
|
|
77
|
+
- `--display-output`: Display output in terminal (md, csv, or json)
|
|
78
|
+
- `--output-folder`: Specify the location of the output folder (default: "./output")
|
|
79
|
+
- `--silent`: Run in silent mode, suppressing output
|
|
80
|
+
- `--run-name`: Specify a name for this run
|
|
81
|
+
- `--cleanup`: Remove output folder before exiting
|
|
82
|
+
|
|
83
|
+
### Examples
|
|
84
|
+
|
|
85
|
+
1. Basic usage with default options:
|
|
86
|
+
```bash
|
|
87
|
+
par_scrape --url "https://openai.com/api/pricing/" --fields "Model" "Pricing Input" "Pricing Output"
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
2. Using Playwright and displaying JSON output:
|
|
91
|
+
```bash
|
|
92
|
+
par_scrape --url "https://example.com" --fields "Title" "Description" "Price" --scraper playwright --display-output json
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
3. Specifying a custom model and output folder:
|
|
96
|
+
```bash
|
|
97
|
+
par_scrape --url "https://example.com" --fields "Title" "Description" "Price" --model gpt-4 --output-folder ./custom_output
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
4. Running in silent mode with a custom run name:
|
|
101
|
+
```bash
|
|
102
|
+
par_scrape --url "https://example.com" --fields "Title" "Description" "Price" --silent --run-name my_custom_run
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
5. Using the cleanup option to remove the output folder after scraping:
|
|
106
|
+
```bash
|
|
107
|
+
par_scrape --url "https://example.com" --fields "Title" "Description" "Price" --cleanup
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## Contributing
|
|
111
|
+
|
|
112
|
+
Contributions are welcome! Please feel free to submit a Pull Request.
|
|
113
|
+
|
|
114
|
+
## License
|
|
115
|
+
|
|
116
|
+
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
|
117
|
+
|
|
118
|
+
## Author
|
|
119
|
+
|
|
120
|
+
Paul Robello - probello@gmail.com
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "par_scrape"
|
|
3
|
+
dynamic = ["version"]
|
|
4
|
+
description = "A versatile web scraping tool with options for Selenium or Playwright, featuring OpenAI-powered data extraction and formatting."
|
|
5
|
+
url = "https://github.com/paulrobello/par_scrape"
|
|
6
|
+
readme = "README.md"
|
|
7
|
+
requires-python = ">=3.11"
|
|
8
|
+
license = { file = "LICENSE" }
|
|
9
|
+
authors = [{ name = "Paul Robello", email = "probello@gmail.com" }]
|
|
10
|
+
maintainers = [{ name = "Paul Robello", email = "probello@gmail.com" }]
|
|
11
|
+
classifiers = [
|
|
12
|
+
"License :: OSI Approved :: MIT License",
|
|
13
|
+
"Environment :: Console",
|
|
14
|
+
"Development Status :: 4 - Beta",
|
|
15
|
+
"Intended Audience :: Developers",
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Programming Language :: Python :: 3.11",
|
|
18
|
+
"Programming Language :: Python :: 3.12",
|
|
19
|
+
"Topic :: Internet :: WWW/HTTP :: Browsers",
|
|
20
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
21
|
+
"Topic :: Text Processing :: Markup :: HTML",
|
|
22
|
+
"Typing :: Typed"
|
|
23
|
+
]
|
|
24
|
+
keywords = ["web scraping", "selenium", "playwright", "openai", "data extraction"]
|
|
25
|
+
dependencies = [
|
|
26
|
+
"aiofiles>=24.1.0",
|
|
27
|
+
"beautifulsoup4>=4.12.3",
|
|
28
|
+
"html2text>=2024.2.26",
|
|
29
|
+
"openai>=1.43.0",
|
|
30
|
+
"openpyxl>=3.1.5",
|
|
31
|
+
"pandas>=2.2.2",
|
|
32
|
+
"playwright>=1.46.0",
|
|
33
|
+
"pydantic>=2.9.0",
|
|
34
|
+
"python-dotenv>=1.0.1",
|
|
35
|
+
"rich>=13.8.0",
|
|
36
|
+
"selenium>=4.24.0",
|
|
37
|
+
"tabulate>=0.9.0",
|
|
38
|
+
"tiktoken>=0.7.0",
|
|
39
|
+
"typer>=0.12.5",
|
|
40
|
+
"typing-extensions>=4.12.2",
|
|
41
|
+
"webdriver-manager>=4.0.2",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
packages = [
|
|
45
|
+
"src/par_scrape"
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
[project.urls]
|
|
49
|
+
Homepage = "https://github.com/paulrobello/par_scrape"
|
|
50
|
+
Documentation = "https://github.com/paulrobello/par_scrape/blob/main/README.md"
|
|
51
|
+
Repository = "https://github.com/paulrobello/par_scrape"
|
|
52
|
+
Issues = "https://github.com/paulrobello/par_scrape/issues"
|
|
53
|
+
Discussions = "https://github.com/paulrobello/par_scrape/discussions"
|
|
54
|
+
Wiki = "https://github.com/paulrobello/par_scrape/wiki"
|
|
55
|
+
|
|
56
|
+
[project.scripts]
|
|
57
|
+
par_scrape = "par_scrape.__main__:app"
|
|
58
|
+
|
|
59
|
+
[build-system]
|
|
60
|
+
requires = ["hatchling", "wheel"]
|
|
61
|
+
build-backend = "hatchling.build"
|
|
62
|
+
|
|
63
|
+
[tool.uv]
|
|
64
|
+
dev-dependencies = [
|
|
65
|
+
"build>=1.2.1",
|
|
66
|
+
"twine>=5.1.1",
|
|
67
|
+
"black>=24.8.0",
|
|
68
|
+
"pylint>=3.2.7",
|
|
69
|
+
"pyright>=1.1.379",
|
|
70
|
+
"pre-commit>=3.8.0",
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
[tool.hatch.version]
|
|
74
|
+
path = "src/par_scrape/__init__.py"
|
|
75
|
+
|
|
76
|
+
[tool.hatch.build.targets.wheel]
|
|
77
|
+
packages = ["src/par_scrape"]
|
|
78
|
+
include = [
|
|
79
|
+
"*.py",
|
|
80
|
+
"py.typed",
|
|
81
|
+
"*.png",
|
|
82
|
+
"*.md",
|
|
83
|
+
"*.tcss",
|
|
84
|
+
"*.png",
|
|
85
|
+
"*.md",
|
|
86
|
+
"*.tcss"
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
[tool.hatch.build.targets.sdist]
|
|
90
|
+
include = [
|
|
91
|
+
"src/par_scrape",
|
|
92
|
+
"LICENSE",
|
|
93
|
+
"README.md",
|
|
94
|
+
"pyproject.toml"
|
|
95
|
+
]
|
|
96
|
+
exclude = [
|
|
97
|
+
"*.pyc",
|
|
98
|
+
"__pycache__",
|
|
99
|
+
"*.so",
|
|
100
|
+
"*.dylib"
|
|
101
|
+
]
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""PAR Scrape - A versatile web scraping tool."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
__author__ = "Paul Robello"
|
|
8
|
+
__copyright__ = "Copyright 2024, Paul Robello"
|
|
9
|
+
__credits__ = ["Paul Robello"]
|
|
10
|
+
__maintainer__ = "Paul Robello"
|
|
11
|
+
__email__ = "probello@gmail.com"
|
|
12
|
+
__version__ = "0.1.0"
|
|
13
|
+
__licence__ = "MIT"
|
|
14
|
+
__application_title__ = "PAR Scrape"
|
|
15
|
+
__application_binary__ = "par_scrape"
|
|
16
|
+
|
|
17
|
+
os.environ["USER_AGENT"] = f"{__application_title__} {__version__}"
|
|
18
|
+
|
|
19
|
+
__all__: list[str] = [
|
|
20
|
+
"__author__",
|
|
21
|
+
"__copyright__",
|
|
22
|
+
"__credits__",
|
|
23
|
+
"__maintainer__",
|
|
24
|
+
"__email__",
|
|
25
|
+
"__version__",
|
|
26
|
+
"__licence__",
|
|
27
|
+
"__application_title__",
|
|
28
|
+
"__application_binary__",
|
|
29
|
+
]
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
"""Main entry point for par_scrape."""
|
|
2
|
+
|
|
3
|
+
import csv
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import asyncio
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from io import StringIO
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import List, Optional, Annotated
|
|
12
|
+
from enum import Enum
|
|
13
|
+
from uuid import uuid4
|
|
14
|
+
from contextlib import nullcontext
|
|
15
|
+
from asyncio import run as aiorun
|
|
16
|
+
import aiofiles
|
|
17
|
+
import aiofiles.os as aos
|
|
18
|
+
|
|
19
|
+
import typer
|
|
20
|
+
from dotenv import load_dotenv
|
|
21
|
+
from rich.markdown import Markdown
|
|
22
|
+
from rich.panel import Panel
|
|
23
|
+
from rich.syntax import Syntax
|
|
24
|
+
from rich.table import Table
|
|
25
|
+
from rich.text import Text
|
|
26
|
+
|
|
27
|
+
from par_scrape.scrape_data import (
|
|
28
|
+
save_raw_data,
|
|
29
|
+
create_dynamic_listing_model,
|
|
30
|
+
create_listings_container_model,
|
|
31
|
+
format_data,
|
|
32
|
+
save_formatted_data,
|
|
33
|
+
)
|
|
34
|
+
from par_scrape.fetch_html import (
|
|
35
|
+
fetch_html_selenium,
|
|
36
|
+
fetch_html_playwright,
|
|
37
|
+
html_to_markdown_with_readability,
|
|
38
|
+
)
|
|
39
|
+
from par_scrape.pricing import calculate_price
|
|
40
|
+
from par_scrape.utils import console
|
|
41
|
+
from par_scrape import __version__, __application_title__
|
|
42
|
+
|
|
43
|
+
# Load the .env file from the project folder
|
|
44
|
+
load_dotenv(dotenv_path=".env")
|
|
45
|
+
# Load the .env file from the users home folder
|
|
46
|
+
load_dotenv(dotenv_path=Path("~/.par-scrape.env").expanduser())
|
|
47
|
+
|
|
48
|
+
# Initialize Typer app
|
|
49
|
+
app = typer.Typer(help="Web scraping tool with options for Selenium or Playwright")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class DisplayOutputFormat(str, Enum):
|
|
53
|
+
"""Enum for display output format choices."""
|
|
54
|
+
|
|
55
|
+
MD = "md"
|
|
56
|
+
CSV = "csv"
|
|
57
|
+
JSON = "json"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class ScraperChoice(str, Enum):
|
|
61
|
+
"""Enum for scraper choices."""
|
|
62
|
+
|
|
63
|
+
SELENIUM = "selenium"
|
|
64
|
+
PLAYWRIGHT = "playwright"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def version_callback(value: bool) -> None:
|
|
68
|
+
"""Print version and exit."""
|
|
69
|
+
if value:
|
|
70
|
+
print(f"{__application_title__}: {__version__}")
|
|
71
|
+
raise typer.Exit()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# pylint: disable=too-many-statements,dangerous-default-value
|
|
75
|
+
@app.command()
|
|
76
|
+
def main(
|
|
77
|
+
url: Annotated[
|
|
78
|
+
str, typer.Option("--url", "-u", help="URL to scrape", prompt=True)
|
|
79
|
+
] = "https://openai.com/api/pricing/",
|
|
80
|
+
fields: Annotated[
|
|
81
|
+
List[str],
|
|
82
|
+
typer.Option(
|
|
83
|
+
"--fields", "-f", help="Fields to extract from the webpage", prompt=True
|
|
84
|
+
),
|
|
85
|
+
] = [
|
|
86
|
+
"Model",
|
|
87
|
+
"Pricing Input",
|
|
88
|
+
"Pricing Output",
|
|
89
|
+
],
|
|
90
|
+
scraper: Annotated[
|
|
91
|
+
ScraperChoice,
|
|
92
|
+
typer.Option(
|
|
93
|
+
"--scraper",
|
|
94
|
+
"-s",
|
|
95
|
+
help="Scraper to use: 'selenium' or 'playwright'",
|
|
96
|
+
case_sensitive=False,
|
|
97
|
+
),
|
|
98
|
+
] = ScraperChoice.SELENIUM,
|
|
99
|
+
remove_output: Annotated[
|
|
100
|
+
bool,
|
|
101
|
+
typer.Option(
|
|
102
|
+
"--remove-output", "-r", help="Remove output folder before running"
|
|
103
|
+
),
|
|
104
|
+
] = False,
|
|
105
|
+
headless: Annotated[
|
|
106
|
+
bool,
|
|
107
|
+
typer.Option("--headless", "-h", help="Run in headless mode (for Selenium)"),
|
|
108
|
+
] = True,
|
|
109
|
+
model: Annotated[
|
|
110
|
+
str, typer.Option("--model", "-m", help="OpenAI model to use for processing")
|
|
111
|
+
] = "gpt-4o-mini",
|
|
112
|
+
display_output: Annotated[
|
|
113
|
+
Optional[DisplayOutputFormat],
|
|
114
|
+
typer.Option(
|
|
115
|
+
"--display-output",
|
|
116
|
+
"-d",
|
|
117
|
+
help="Display output in terminal (md, csv, or json)",
|
|
118
|
+
),
|
|
119
|
+
] = None,
|
|
120
|
+
output_folder: Annotated[
|
|
121
|
+
Path,
|
|
122
|
+
typer.Option(
|
|
123
|
+
"--output-folder", "-o", help="Specify the location of the output folder"
|
|
124
|
+
),
|
|
125
|
+
] = "./output",
|
|
126
|
+
silent: Annotated[
|
|
127
|
+
bool,
|
|
128
|
+
typer.Option("--silent", "-q", help="Run in silent mode, suppressing output"),
|
|
129
|
+
] = False,
|
|
130
|
+
run_name: Annotated[
|
|
131
|
+
str,
|
|
132
|
+
typer.Option("--run-name", "-n", help="Specify a name for this run"),
|
|
133
|
+
] = "",
|
|
134
|
+
version: Annotated[ # pylint: disable=unused-argument
|
|
135
|
+
Optional[bool],
|
|
136
|
+
typer.Option("--version", "-v", callback=version_callback, is_eager=True),
|
|
137
|
+
] = None,
|
|
138
|
+
cleanup: Annotated[
|
|
139
|
+
bool,
|
|
140
|
+
typer.Option("--cleanup", "-c", help="Remove output folder before exiting"),
|
|
141
|
+
] = False,
|
|
142
|
+
):
|
|
143
|
+
"""Scrape and analyze data from a website."""
|
|
144
|
+
|
|
145
|
+
async def _main(): # pylint: disable=too-many-locals, too-many-branches, too-many-statements
|
|
146
|
+
"""Scrape and analyze data from a website."""
|
|
147
|
+
nonlocal run_name
|
|
148
|
+
if not os.environ.get("OPENAI_API_KEY"):
|
|
149
|
+
console.print(
|
|
150
|
+
"[bold red]OPENAI_API_KEY environment variable not set. Exiting...[/bold red]"
|
|
151
|
+
)
|
|
152
|
+
raise typer.Exit(1)
|
|
153
|
+
with console.capture() if silent else nullcontext():
|
|
154
|
+
if remove_output:
|
|
155
|
+
if await aos.path.exists(output_folder):
|
|
156
|
+
shutil.rmtree(output_folder)
|
|
157
|
+
console.print(
|
|
158
|
+
f"[bold green]Removed existing output folder: {output_folder}[/bold green]"
|
|
159
|
+
)
|
|
160
|
+
try:
|
|
161
|
+
# Generate run_name if not provided
|
|
162
|
+
if not run_name:
|
|
163
|
+
run_name = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
164
|
+
else:
|
|
165
|
+
# Ensure run_name is filesystem-friendly
|
|
166
|
+
run_name = "".join(
|
|
167
|
+
c for c in run_name if c.isalnum() or c in ("-", "_")
|
|
168
|
+
)
|
|
169
|
+
if not run_name:
|
|
170
|
+
run_name = str(uuid4())
|
|
171
|
+
|
|
172
|
+
# Display summary of options
|
|
173
|
+
console.print(
|
|
174
|
+
Panel.fit(
|
|
175
|
+
Text.assemble(
|
|
176
|
+
("URL: ", "cyan"),
|
|
177
|
+
(f"{url}", "green"),
|
|
178
|
+
"\n",
|
|
179
|
+
("Model: ", "cyan"),
|
|
180
|
+
(f"{model}", "green"),
|
|
181
|
+
"\n",
|
|
182
|
+
("Scraper: ", "cyan"),
|
|
183
|
+
(f"{scraper}", "green"),
|
|
184
|
+
"\n",
|
|
185
|
+
("Headless: ", "cyan"),
|
|
186
|
+
(f"{headless}", "green"),
|
|
187
|
+
"\n",
|
|
188
|
+
("Fields to extract: ", "cyan"),
|
|
189
|
+
(", ".join(fields), "green"),
|
|
190
|
+
"\n",
|
|
191
|
+
("Display output: ", "cyan"),
|
|
192
|
+
(f"{display_output or 'None'}", "green"),
|
|
193
|
+
"\n",
|
|
194
|
+
("Silent mode: ", "cyan"),
|
|
195
|
+
(f"{silent}", "green"),
|
|
196
|
+
),
|
|
197
|
+
title="[bold]Scraping Configuration",
|
|
198
|
+
border_style="bold",
|
|
199
|
+
)
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
with console.status(
|
|
203
|
+
"[bold green]Working on data extraction and processing..."
|
|
204
|
+
) as status:
|
|
205
|
+
# Scrape data
|
|
206
|
+
status.update("[bold cyan]Fetching HTML...")
|
|
207
|
+
if scraper == ScraperChoice.PLAYWRIGHT:
|
|
208
|
+
raw_html = await fetch_html_playwright(url)
|
|
209
|
+
else:
|
|
210
|
+
raw_html = await fetch_html_selenium(url, headless)
|
|
211
|
+
|
|
212
|
+
status.update("[bold cyan]Converting HTML to Markdown...")
|
|
213
|
+
markdown = await html_to_markdown_with_readability(raw_html)
|
|
214
|
+
|
|
215
|
+
# Save raw data
|
|
216
|
+
status.update("[bold cyan]Saving raw data...")
|
|
217
|
+
await save_raw_data(markdown, run_name, output_folder)
|
|
218
|
+
|
|
219
|
+
# Create the dynamic listing model
|
|
220
|
+
status.update("[bold cyan]Creating dynamic models...")
|
|
221
|
+
dynamic_listing_model = create_dynamic_listing_model(fields)
|
|
222
|
+
dynamic_listings_container = create_listings_container_model(
|
|
223
|
+
dynamic_listing_model
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
# Format data
|
|
227
|
+
status.update("[bold cyan]Formatting data...")
|
|
228
|
+
formatted_data = await format_data(
|
|
229
|
+
markdown, dynamic_listings_container, model
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
# Save formatted data
|
|
233
|
+
status.update("[bold cyan]Saving formatted data...")
|
|
234
|
+
_, file_paths = await save_formatted_data(
|
|
235
|
+
formatted_data, run_name, output_folder
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
# Convert formatted_data back to text for token counting
|
|
239
|
+
formatted_data_text = json.dumps(formatted_data.model_dump())
|
|
240
|
+
|
|
241
|
+
# Display output if requested
|
|
242
|
+
if display_output:
|
|
243
|
+
if display_output.value in file_paths:
|
|
244
|
+
async with aiofiles.open(
|
|
245
|
+
file_paths[display_output.value], "rt", encoding="utf-8"
|
|
246
|
+
) as f:
|
|
247
|
+
content = await f.read()
|
|
248
|
+
if display_output == DisplayOutputFormat.MD:
|
|
249
|
+
console.print(Markdown(content))
|
|
250
|
+
elif display_output == DisplayOutputFormat.CSV:
|
|
251
|
+
# Convert CSV to rich Table
|
|
252
|
+
table = Table(title="CSV Data")
|
|
253
|
+
csv_reader = csv.reader(StringIO(content))
|
|
254
|
+
headers = next(csv_reader)
|
|
255
|
+
for header in headers:
|
|
256
|
+
table.add_column(header, style="cyan")
|
|
257
|
+
for row in csv_reader:
|
|
258
|
+
table.add_row(*row)
|
|
259
|
+
console.print(table)
|
|
260
|
+
elif display_output == DisplayOutputFormat.JSON:
|
|
261
|
+
console.print(Syntax(content, "json"))
|
|
262
|
+
else:
|
|
263
|
+
console.print(
|
|
264
|
+
f"[bold red]Invalid output type: {display_output.value}[/bold red]"
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
# Automatically calculate the token usage and cost for all input and output
|
|
268
|
+
status.update("[bold cyan]Calculating token usage and cost...")
|
|
269
|
+
input_tokens, output_tokens, total_cost = await calculate_price(
|
|
270
|
+
markdown, formatted_data_text, model=model
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
console.print(
|
|
274
|
+
Panel.fit(
|
|
275
|
+
Text.assemble(
|
|
276
|
+
("Input token count: ", "cyan"),
|
|
277
|
+
(f"{input_tokens}", "green"),
|
|
278
|
+
"\n",
|
|
279
|
+
("Output token count: ", "cyan"),
|
|
280
|
+
(f"{output_tokens}", "green"),
|
|
281
|
+
"\n",
|
|
282
|
+
("Estimated total cost: ", "cyan"),
|
|
283
|
+
(f"${total_cost:.4f}", "green bold"),
|
|
284
|
+
),
|
|
285
|
+
title="[bold]Summary",
|
|
286
|
+
border_style="bold",
|
|
287
|
+
)
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
except Exception as e: # pylint: disable=broad-except
|
|
291
|
+
console.print(f"[bold red]An error occurred:[/bold red] {str(e)}")
|
|
292
|
+
|
|
293
|
+
finally:
|
|
294
|
+
if cleanup:
|
|
295
|
+
with console.status("[bold yellow]Cleaning up..."):
|
|
296
|
+
if await aos.path.exists(output_folder):
|
|
297
|
+
await asyncio.to_thread(shutil.rmtree, output_folder)
|
|
298
|
+
console.print(
|
|
299
|
+
f"[bold green]Removed output folder and its contents: {output_folder}[/bold green]"
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
aiorun(_main())
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
if __name__ == "__main__":
|
|
306
|
+
app()
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""Fetch HTML from URL and save it to a file."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import asyncio
|
|
7
|
+
import random
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import aiofiles
|
|
11
|
+
|
|
12
|
+
import html2text
|
|
13
|
+
from bs4 import BeautifulSoup
|
|
14
|
+
from playwright.async_api import async_playwright
|
|
15
|
+
from selenium import webdriver
|
|
16
|
+
from selenium.webdriver.chrome.webdriver import WebDriver
|
|
17
|
+
from selenium.webdriver.chrome.options import Options
|
|
18
|
+
from selenium.webdriver.chrome.service import Service
|
|
19
|
+
from webdriver_manager.chrome import ChromeDriverManager
|
|
20
|
+
|
|
21
|
+
from .utils import console
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
async def setup_selenium(headless: bool = True) -> WebDriver:
|
|
25
|
+
"""Set up ChromeDriver for Selenium."""
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger("selenium")
|
|
28
|
+
logger.setLevel(logging.DEBUG)
|
|
29
|
+
|
|
30
|
+
options = Options()
|
|
31
|
+
|
|
32
|
+
# adding arguments
|
|
33
|
+
options.add_argument("--disable-gpu")
|
|
34
|
+
options.add_argument("--disable-dev-shm-usage")
|
|
35
|
+
options.add_argument("--window-size=1920,1080")
|
|
36
|
+
options.add_experimental_option(
|
|
37
|
+
"excludeSwitches", ["enable-logging"]
|
|
38
|
+
) # Disable logging
|
|
39
|
+
options.add_argument("--log-level=3") # Suppress console logging
|
|
40
|
+
options.add_argument("--silent")
|
|
41
|
+
options.add_argument("--disable-extensions")
|
|
42
|
+
|
|
43
|
+
# Enable headless mode if specified
|
|
44
|
+
if headless:
|
|
45
|
+
options.add_argument("--headless")
|
|
46
|
+
|
|
47
|
+
# Randomize user-agent to mimic different users
|
|
48
|
+
options.add_argument(
|
|
49
|
+
"user-agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36" # pylint: disable=line-too-long
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
# Load ChromeDriver path from config.json
|
|
53
|
+
config_path = Path("config.json")
|
|
54
|
+
if config_path.exists():
|
|
55
|
+
async with aiofiles.open(config_path, "rt", encoding="utf-8") as config_file:
|
|
56
|
+
config = json.loads(await config_file.read())
|
|
57
|
+
chromedriver_path = config.get("chromedriver_path", "")
|
|
58
|
+
else:
|
|
59
|
+
chromedriver_path = ""
|
|
60
|
+
|
|
61
|
+
# Check if ChromeDriver exists, if not, download it
|
|
62
|
+
if not chromedriver_path or not os.path.exists(chromedriver_path):
|
|
63
|
+
console.print(
|
|
64
|
+
"[yellow]ChromeDriver not found or path invalid. Attempting to download...[/yellow]"
|
|
65
|
+
)
|
|
66
|
+
try:
|
|
67
|
+
chromedriver_path = await asyncio.to_thread(ChromeDriverManager().install)
|
|
68
|
+
console.print(
|
|
69
|
+
f"[green]ChromeDriver downloaded successfully to: {chromedriver_path}[/green]"
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
# Save the new path to config.json
|
|
73
|
+
async with aiofiles.open(
|
|
74
|
+
config_path, "wt", encoding="utf-8"
|
|
75
|
+
) as config_file:
|
|
76
|
+
await config_file.write(
|
|
77
|
+
json.dumps({"chromedriver_path": chromedriver_path}, indent=4)
|
|
78
|
+
)
|
|
79
|
+
console.print("[green]Updated ChromeDriver path in config.json[/green]")
|
|
80
|
+
except Exception as e:
|
|
81
|
+
console.print(
|
|
82
|
+
f"[bold red]Error downloading ChromeDriver:[/bold red] {str(e)}"
|
|
83
|
+
)
|
|
84
|
+
raise
|
|
85
|
+
|
|
86
|
+
service = Service(chromedriver_path, log_output=os.devnull)
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
# Initialize the WebDriver
|
|
90
|
+
driver = await asyncio.to_thread(
|
|
91
|
+
webdriver.Chrome, service=service, options=options
|
|
92
|
+
)
|
|
93
|
+
return driver
|
|
94
|
+
except Exception as e:
|
|
95
|
+
console.print(
|
|
96
|
+
f"[bold red]Error initializing Chrome WebDriver:[/bold red] {str(e)}"
|
|
97
|
+
)
|
|
98
|
+
console.print(
|
|
99
|
+
"[yellow]Please ensure Chrome is installed and up to date.[/yellow]"
|
|
100
|
+
)
|
|
101
|
+
console.print(
|
|
102
|
+
"[yellow]Also, make sure the ChromeDriver version matches your Chrome browser version.[/yellow]"
|
|
103
|
+
)
|
|
104
|
+
raise
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
async def fetch_html_selenium(url: str, headless: bool = True) -> str:
|
|
108
|
+
"""Fetch HTML content from a URL using Selenium."""
|
|
109
|
+
driver = await setup_selenium(headless)
|
|
110
|
+
try:
|
|
111
|
+
await asyncio.to_thread(driver.get, url)
|
|
112
|
+
|
|
113
|
+
# Add random delays to mimic human behavior
|
|
114
|
+
await asyncio.sleep(
|
|
115
|
+
random.uniform(3, 6)
|
|
116
|
+
) # Adjust this to simulate time for user to read or interact
|
|
117
|
+
|
|
118
|
+
# Add more realistic actions like scrolling
|
|
119
|
+
await asyncio.to_thread(
|
|
120
|
+
driver.execute_script, "window.scrollTo(0, document.body.scrollHeight);"
|
|
121
|
+
)
|
|
122
|
+
await asyncio.sleep(
|
|
123
|
+
random.uniform(3, 5)
|
|
124
|
+
) # Simulate time taken to scroll and read
|
|
125
|
+
|
|
126
|
+
html = await asyncio.to_thread(lambda: driver.page_source)
|
|
127
|
+
return html
|
|
128
|
+
finally:
|
|
129
|
+
await asyncio.to_thread(driver.quit)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
async def fetch_html_playwright(url: str) -> str:
|
|
133
|
+
"""
|
|
134
|
+
Fetch HTML content from a URL using Playwright.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
url (str): The URL to fetch HTML from.
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
str: The HTML content of the page.
|
|
141
|
+
"""
|
|
142
|
+
async with async_playwright() as p:
|
|
143
|
+
browser = await p.chromium.launch(headless=True)
|
|
144
|
+
page = await browser.new_page()
|
|
145
|
+
await page.goto(url)
|
|
146
|
+
|
|
147
|
+
# Add random delays to mimic human behavior
|
|
148
|
+
await asyncio.sleep(
|
|
149
|
+
3
|
|
150
|
+
) # Adjust this to simulate time for user to read or interact
|
|
151
|
+
|
|
152
|
+
# Add more realistic actions like scrolling
|
|
153
|
+
await page.evaluate("window.scrollTo(0, document.body.scrollHeight)")
|
|
154
|
+
await asyncio.sleep(3) # Simulate time taken to scroll and read
|
|
155
|
+
|
|
156
|
+
html = await page.content()
|
|
157
|
+
await browser.close()
|
|
158
|
+
return html
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
async def clean_html(html_content: str) -> str:
|
|
162
|
+
"""
|
|
163
|
+
Clean HTML content by removing headers and footers.
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
html_content (str): The raw HTML content.
|
|
167
|
+
|
|
168
|
+
Returns:
|
|
169
|
+
str: The cleaned HTML content.
|
|
170
|
+
"""
|
|
171
|
+
soup = BeautifulSoup(html_content, "html.parser")
|
|
172
|
+
|
|
173
|
+
# Remove headers and footers based on common HTML tags or classes
|
|
174
|
+
for element in soup.find_all(["header", "footer"]):
|
|
175
|
+
element.decompose() # Remove these tags and their content
|
|
176
|
+
|
|
177
|
+
return str(soup)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
async def html_to_markdown_with_readability(html_content: str) -> str:
|
|
181
|
+
"""
|
|
182
|
+
Convert HTML content to Markdown format.
|
|
183
|
+
|
|
184
|
+
Args:
|
|
185
|
+
html_content (str): The HTML content to convert.
|
|
186
|
+
|
|
187
|
+
Returns:
|
|
188
|
+
str: The converted markdown content.
|
|
189
|
+
"""
|
|
190
|
+
cleaned_html = await clean_html(html_content)
|
|
191
|
+
|
|
192
|
+
# Convert to markdown
|
|
193
|
+
markdown_converter = html2text.HTML2Text()
|
|
194
|
+
markdown_converter.ignore_links = False
|
|
195
|
+
markdown_content = await asyncio.to_thread(markdown_converter.handle, cleaned_html)
|
|
196
|
+
|
|
197
|
+
return markdown_content
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Pricing functions for Par Scrape."""
|
|
2
|
+
|
|
3
|
+
from typing import Tuple
|
|
4
|
+
import asyncio
|
|
5
|
+
|
|
6
|
+
import tiktoken
|
|
7
|
+
|
|
8
|
+
pricing = {
|
|
9
|
+
"gpt-4o": {
|
|
10
|
+
"input": 0.000005, # $5.00 per 1M input tokens
|
|
11
|
+
"output": 0.000015, # $15.00 per 1M output tokens
|
|
12
|
+
},
|
|
13
|
+
"gpt-4o-2024-08-06": {
|
|
14
|
+
"input": 0.0000025, # $2.50 per 1M input tokens
|
|
15
|
+
"output": 0.00001, # $10.00 per 1M output tokens
|
|
16
|
+
},
|
|
17
|
+
"gpt-4o-2024-05-13": {
|
|
18
|
+
"input": 0.000005, # $5.00 per 1M input tokens
|
|
19
|
+
"output": 0.000015, # $15.00 per 1M output tokens
|
|
20
|
+
},
|
|
21
|
+
"gpt-4o-mini": {
|
|
22
|
+
"input": 0.00000015, # $0.15 per 1M input tokens
|
|
23
|
+
"output": 0.0000006, # $0.60 per 1M output tokens
|
|
24
|
+
},
|
|
25
|
+
"gpt-4o-mini-2024-07-18": {
|
|
26
|
+
"input": 0.00000015, # $0.15 per 1M input tokens
|
|
27
|
+
"output": 0.0000006, # $0.60 per 1M output tokens
|
|
28
|
+
},
|
|
29
|
+
"gpt-4": {
|
|
30
|
+
"input": 0.00003, # $30.00 per 1M input tokens
|
|
31
|
+
"output": 0.00006, # $60.00 per 1M output tokens
|
|
32
|
+
},
|
|
33
|
+
"gpt-4-32k": {
|
|
34
|
+
"input": 0.00006, # $60.00 per 1M input tokens
|
|
35
|
+
"output": 0.00012, # $120.00 per 1M output tokens
|
|
36
|
+
},
|
|
37
|
+
"gpt-4-turbo": {
|
|
38
|
+
"input": 0.00001, # $10.00 per 1M input tokens
|
|
39
|
+
"output": 0.00003, # $30.00 per 1M output tokens
|
|
40
|
+
},
|
|
41
|
+
"gpt-4-turbo-2024-04-09": {
|
|
42
|
+
"input": 0.00001, # $10.00 per 1M input tokens
|
|
43
|
+
"output": 0.00003, # $30.00 per 1M output tokens
|
|
44
|
+
},
|
|
45
|
+
"gpt-3.5-turbo-0125": {
|
|
46
|
+
"input": 0.0000005, # $0.50 per 1M input tokens
|
|
47
|
+
"output": 0.0000015, # $1.50 per 1M output tokens
|
|
48
|
+
},
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
async def calculate_price(
|
|
53
|
+
input_text: str, output_text: str, model: str
|
|
54
|
+
) -> Tuple[int, int, float]:
|
|
55
|
+
"""
|
|
56
|
+
Calculate the price for processing input and output text using a specified model.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
input_text (str): The input text.
|
|
60
|
+
output_text (str): The output text.
|
|
61
|
+
model (str, optional): The model to use for pricing. Defaults to model_used.
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
Tuple[int, int, float]: A tuple containing the input token count, output token count, and total cost.
|
|
65
|
+
Returns (0, 0, 0.0) if there's an error during calculation.
|
|
66
|
+
"""
|
|
67
|
+
try:
|
|
68
|
+
# Initialize the encoder for the specific model
|
|
69
|
+
encoder = tiktoken.encoding_for_model(model)
|
|
70
|
+
|
|
71
|
+
# Encode the input text to get the number of input tokens
|
|
72
|
+
input_token_count = await asyncio.to_thread(len, encoder.encode(input_text))
|
|
73
|
+
|
|
74
|
+
# Encode the output text to get the number of output tokens
|
|
75
|
+
output_token_count = await asyncio.to_thread(len, encoder.encode(output_text))
|
|
76
|
+
|
|
77
|
+
# Calculate the costs
|
|
78
|
+
# Convert price per million tokens to price per token, then multiply by token count
|
|
79
|
+
input_cost = input_token_count * pricing[model]["input"]
|
|
80
|
+
output_cost = output_token_count * pricing[model]["output"]
|
|
81
|
+
total_cost = input_cost + output_cost
|
|
82
|
+
|
|
83
|
+
return input_token_count, output_token_count, total_cost
|
|
84
|
+
except Exception as e: # pylint: disable=broad-except
|
|
85
|
+
print(f"Error calculating price: {str(e)}")
|
|
86
|
+
return 0, 0, 0.0
|
|
File without changes
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""Scrape data from Web."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from typing import List, Type, Tuple, Dict
|
|
6
|
+
|
|
7
|
+
import aiofiles
|
|
8
|
+
import pandas as pd
|
|
9
|
+
import tiktoken
|
|
10
|
+
from aiofiles import os as aos
|
|
11
|
+
from openai import AsyncOpenAI, OpenAIError
|
|
12
|
+
from pydantic import BaseModel, create_model, ConfigDict
|
|
13
|
+
from rich.panel import Panel
|
|
14
|
+
|
|
15
|
+
from par_scrape.utils import console
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
async def save_raw_data(
|
|
19
|
+
raw_data: str, run_name: str, output_folder: str = "output"
|
|
20
|
+
) -> str:
|
|
21
|
+
"""
|
|
22
|
+
Save raw data to a file.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
raw_data (str): The raw data to save.
|
|
26
|
+
run_name (str): The run name to use in the filename.
|
|
27
|
+
output_folder (str, optional): The folder to save the file in. Defaults to 'output'.
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
str: The path to the saved file.
|
|
31
|
+
"""
|
|
32
|
+
# Ensure the output folder exists
|
|
33
|
+
await aos.makedirs(output_folder, exist_ok=True)
|
|
34
|
+
|
|
35
|
+
# Save the raw markdown data with run_name in filename
|
|
36
|
+
raw_output_path = os.path.join(output_folder, f"rawData_{run_name}.md")
|
|
37
|
+
async with aiofiles.open(raw_output_path, "wt", encoding="utf-8") as f:
|
|
38
|
+
await f.write(raw_data)
|
|
39
|
+
console.print(
|
|
40
|
+
Panel(f"Raw data saved to [bold green]{raw_output_path}[/bold green]")
|
|
41
|
+
)
|
|
42
|
+
return raw_output_path
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def create_dynamic_listing_model(field_names: List[str]) -> Type[BaseModel]:
|
|
46
|
+
"""
|
|
47
|
+
Dynamically creates a Pydantic model based on provided fields.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
field_names (List[str]): A list of names of the fields to extract from the markdown.
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
Type[BaseModel]: A dynamically created Pydantic model.
|
|
54
|
+
"""
|
|
55
|
+
# Create field definitions using aliases for Field parameters
|
|
56
|
+
field_definitions = {field: (str, ...) for field in field_names}
|
|
57
|
+
# Dynamically create the model with all fields
|
|
58
|
+
dynamic_listing_model = create_model(
|
|
59
|
+
"DynamicListingModel",
|
|
60
|
+
**field_definitions,
|
|
61
|
+
)
|
|
62
|
+
dynamic_listing_model.model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
63
|
+
return dynamic_listing_model
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def create_listings_container_model(listing_model: Type[BaseModel]) -> Type[BaseModel]:
|
|
67
|
+
"""
|
|
68
|
+
Create a container model that holds a list of the given listing model.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
listing_model (Type[BaseModel]): The Pydantic model for individual listings.
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
Type[BaseModel]: A container model for a list of listings.
|
|
75
|
+
"""
|
|
76
|
+
return create_model("DynamicListingsContainer", listings=(List[listing_model], ...))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def trim_to_token_limit(text: str, model: str, max_tokens: int = 200000) -> str:
|
|
80
|
+
"""
|
|
81
|
+
Trim text to a specified token limit for a given model.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
text (str): The input text to trim.
|
|
85
|
+
model (str): The model name to use for tokenization.
|
|
86
|
+
max_tokens (int, optional): The maximum number of tokens. Defaults to 200000.
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
str: The trimmed text.
|
|
90
|
+
"""
|
|
91
|
+
encoder = tiktoken.encoding_for_model(model)
|
|
92
|
+
tokens = encoder.encode(text)
|
|
93
|
+
if len(tokens) > max_tokens:
|
|
94
|
+
trimmed_text = encoder.decode(tokens[:max_tokens])
|
|
95
|
+
return trimmed_text
|
|
96
|
+
return text
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
async def format_data(
|
|
100
|
+
data: str, dynamic_listings_container: Type[BaseModel], model: str
|
|
101
|
+
) -> BaseModel:
|
|
102
|
+
"""
|
|
103
|
+
Format data using OpenAI's API asynchronously.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
data (str): The input data to format.
|
|
107
|
+
dynamic_listings_container (Type[BaseModel]): The Pydantic model to use for parsing.
|
|
108
|
+
model (str): The OpenAI model to use for processing.
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
BaseModel: The formatted data as a Pydantic model instance.
|
|
112
|
+
"""
|
|
113
|
+
client = AsyncOpenAI(api_key=os.getenv("OPENAI_API_KEY"))
|
|
114
|
+
|
|
115
|
+
system_message = """
|
|
116
|
+
You are an intelligent text extraction and conversion assistant. Your task is to extract structured information
|
|
117
|
+
from the given text and convert it into a pure JSON format. The JSON should contain only the structured data extracted from the text,
|
|
118
|
+
with no additional commentary, explanations, or extraneous information.
|
|
119
|
+
You could encounter cases where you can't find the data of the fields you have to extract or the data will be in a foreign language.
|
|
120
|
+
Please process the following text and provide the output in pure JSON format with no words before or after the JSON:"""
|
|
121
|
+
|
|
122
|
+
user_message = f"Extract the following information from the provided text:\nPage content:\n\n{data}"
|
|
123
|
+
|
|
124
|
+
try:
|
|
125
|
+
completion = await client.beta.chat.completions.parse(
|
|
126
|
+
model=model,
|
|
127
|
+
messages=[
|
|
128
|
+
{"role": "system", "content": system_message},
|
|
129
|
+
{"role": "user", "content": user_message},
|
|
130
|
+
],
|
|
131
|
+
response_format=dynamic_listings_container,
|
|
132
|
+
)
|
|
133
|
+
return completion.choices[0].message.parsed
|
|
134
|
+
except (OpenAIError, json.JSONDecodeError) as e:
|
|
135
|
+
console.print(
|
|
136
|
+
f"[bold red]Error in API call or parsing response:[/bold red] {str(e)}"
|
|
137
|
+
)
|
|
138
|
+
return dynamic_listings_container(listings=[])
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
async def save_formatted_data(
|
|
142
|
+
formatted_data: BaseModel, run_name: str, output_folder: str = "output"
|
|
143
|
+
) -> Tuple[pd.DataFrame | None, Dict[str, str]]:
|
|
144
|
+
"""
|
|
145
|
+
Save formatted data to JSON, Excel, CSV, and Markdown files.
|
|
146
|
+
|
|
147
|
+
Args:
|
|
148
|
+
formatted_data (BaseModel): The formatted data to save.
|
|
149
|
+
run_name (str): The run name to use in the filenames.
|
|
150
|
+
output_folder (str, optional): The folder to save the files in. Defaults to 'output'.
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
Tuple[pd.DataFrame | None, Dict[str, str]]: The DataFrame created from the formatted data and a dictionary of
|
|
154
|
+
file paths, or None and an empty dict if an error occurred.
|
|
155
|
+
"""
|
|
156
|
+
file_paths: Dict[str, str] = {}
|
|
157
|
+
# Ensure the output folder exists
|
|
158
|
+
await aos.makedirs(output_folder, exist_ok=True)
|
|
159
|
+
|
|
160
|
+
# Prepare formatted data as a dictionary
|
|
161
|
+
formatted_data_dict = formatted_data.model_dump()
|
|
162
|
+
|
|
163
|
+
# Save the formatted data as JSON with run_name in filename
|
|
164
|
+
json_output_path = os.path.join(output_folder, f"sorted_data_{run_name}.json")
|
|
165
|
+
with open(json_output_path, "wt", encoding="utf-8") as f:
|
|
166
|
+
json.dump(formatted_data_dict, f, indent=4)
|
|
167
|
+
console.print(
|
|
168
|
+
Panel(
|
|
169
|
+
f"Formatted data saved to JSON at [bold green]{json_output_path}[/bold green]"
|
|
170
|
+
)
|
|
171
|
+
)
|
|
172
|
+
file_paths["json"] = json_output_path
|
|
173
|
+
|
|
174
|
+
# Prepare data for DataFrame
|
|
175
|
+
if isinstance(formatted_data_dict, dict):
|
|
176
|
+
# If the data is a dictionary containing lists, assume these lists are records
|
|
177
|
+
data_for_df = (
|
|
178
|
+
next(iter(formatted_data_dict.values()))
|
|
179
|
+
if len(formatted_data_dict) == 1
|
|
180
|
+
else formatted_data_dict
|
|
181
|
+
)
|
|
182
|
+
elif isinstance(formatted_data_dict, list):
|
|
183
|
+
data_for_df = formatted_data_dict
|
|
184
|
+
else:
|
|
185
|
+
raise ValueError(
|
|
186
|
+
"Formatted data is neither a dictionary nor a list, cannot convert to DataFrame"
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
# Create DataFrame
|
|
190
|
+
try:
|
|
191
|
+
df = pd.DataFrame(data_for_df)
|
|
192
|
+
console.print(Panel("[bold green]DataFrame created successfully.[/bold green]"))
|
|
193
|
+
|
|
194
|
+
# Save the DataFrame to an Excel file
|
|
195
|
+
excel_output_path = os.path.join(output_folder, f"sorted_data_{run_name}.xlsx")
|
|
196
|
+
df.to_excel(excel_output_path, index=False)
|
|
197
|
+
console.print(
|
|
198
|
+
Panel(
|
|
199
|
+
f"Formatted data saved to Excel at [bold green]{excel_output_path}[/bold green]"
|
|
200
|
+
)
|
|
201
|
+
)
|
|
202
|
+
file_paths["excel"] = excel_output_path
|
|
203
|
+
|
|
204
|
+
# Save the DataFrame to a CSV file
|
|
205
|
+
csv_output_path = os.path.join(output_folder, f"sorted_data_{run_name}.csv")
|
|
206
|
+
df.to_csv(csv_output_path, index=False)
|
|
207
|
+
console.print(
|
|
208
|
+
Panel(
|
|
209
|
+
f"Formatted data saved to CSV at [bold green]{csv_output_path}[/bold green]"
|
|
210
|
+
)
|
|
211
|
+
)
|
|
212
|
+
file_paths["csv"] = csv_output_path
|
|
213
|
+
|
|
214
|
+
# Save the DataFrame as a Markdown table
|
|
215
|
+
markdown_output_path = os.path.join(output_folder, f"sorted_data_{run_name}.md")
|
|
216
|
+
with open(markdown_output_path, "wt", encoding="utf-8") as f:
|
|
217
|
+
f.write(df.to_markdown(index=False) or "")
|
|
218
|
+
console.print(
|
|
219
|
+
Panel(
|
|
220
|
+
f"Formatted data saved as Markdown table at [bold green]{markdown_output_path}[/bold green]"
|
|
221
|
+
)
|
|
222
|
+
)
|
|
223
|
+
file_paths["md"] = markdown_output_path
|
|
224
|
+
|
|
225
|
+
return df, file_paths
|
|
226
|
+
except Exception as e: # pylint: disable=broad-except
|
|
227
|
+
console.print(
|
|
228
|
+
f"[bold red]Error creating DataFrame or saving files:[/bold red] {str(e)}"
|
|
229
|
+
)
|
|
230
|
+
return None, {}
|