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.
@@ -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/
@@ -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
+ [![PyPI](https://img.shields.io/pypi/v/par_scrape)](https://pypi.org/project/par_scrape/)
69
+ [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/par_scrape.svg)](https://pypi.org/project/par_scrape/)
70
+ ![Runs on Linux | MacOS | Windows](https://img.shields.io/badge/runs%20on-Linux%20%7C%20MacOS%20%7C%20Windows-blue)
71
+ ![Arch x86-63 | ARM | AppleSilicon](https://img.shields.io/badge/arch-x86--64%20%7C%20ARM%20%7C%20AppleSilicon-blue)
72
+ ![PyPI - License](https://img.shields.io/pypi/l/par_scrape)
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
+ [!["Buy Me A Coffee"](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://buymeacoffee.com/probello3)
78
+
79
+ ## Screenshots
80
+ ![PAR Scrape Screenshot](https://raw.githubusercontent.com/paulrobello/par_scrape/main/Screenshot.png)
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
+ [![PyPI](https://img.shields.io/pypi/v/par_scrape)](https://pypi.org/project/par_scrape/)
4
+ [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/par_scrape.svg)](https://pypi.org/project/par_scrape/)
5
+ ![Runs on Linux | MacOS | Windows](https://img.shields.io/badge/runs%20on-Linux%20%7C%20MacOS%20%7C%20Windows-blue)
6
+ ![Arch x86-63 | ARM | AppleSilicon](https://img.shields.io/badge/arch-x86--64%20%7C%20ARM%20%7C%20AppleSilicon-blue)
7
+ ![PyPI - License](https://img.shields.io/pypi/l/par_scrape)
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
+ [!["Buy Me A Coffee"](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://buymeacoffee.com/probello3)
13
+
14
+ ## Screenshots
15
+ ![PAR Scrape Screenshot](https://raw.githubusercontent.com/paulrobello/par_scrape/main/Screenshot.png)
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, {}
@@ -0,0 +1,6 @@
1
+ """Utility functions for par_scrape."""
2
+
3
+ from rich.console import Console
4
+
5
+ # Initialize rich console
6
+ console = Console()